modern-cms 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/README.md +41 -0
- package/bin/lib.js +97 -0
- package/bin/modern-cms.js +197 -0
- package/package.json +37 -0
- package/template/.env.example +48 -0
- package/template/README.md +210 -0
- package/template/apps/api/Dockerfile +26 -0
- package/template/apps/api/package.json +45 -0
- package/template/apps/api/prisma/backfill-search-text.ts +31 -0
- package/template/apps/api/prisma/migrations/20260701034530_init/migration.sql +86 -0
- package/template/apps/api/prisma/migrations/20260701081942_add_publish_at_and_revisions/migration.sql +19 -0
- package/template/apps/api/prisma/migrations/20260701085011_add_form_submissions/migration.sql +13 -0
- package/template/apps/api/prisma/migrations/20260702084049_add_global_sections_and_saved_blocks/migration.sql +24 -0
- package/template/apps/api/prisma/migrations/20260702085609_add_page_locale_and_translations/migration.sql +6 -0
- package/template/apps/api/prisma/migrations/20260702091914_add_collections/migration.sql +38 -0
- package/template/apps/api/prisma/migrations/20260702120000_platform_hardening/migration.sql +15 -0
- package/template/apps/api/prisma/migrations/20260705031230_add_collections_redirects_media_meta/migration.sql +17 -0
- package/template/apps/api/prisma/migrations/20260705033241_add_audit_log/migration.sql +18 -0
- package/template/apps/api/prisma/migrations/20260706031301_standard_cms_platform_pass/migration.sql +48 -0
- package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
- package/template/apps/api/prisma/reset-admin.ts +19 -0
- package/template/apps/api/prisma/schema.prisma +251 -0
- package/template/apps/api/prisma/seed-homepage.ts +752 -0
- package/template/apps/api/prisma/seed-our-charities.ts +231 -0
- package/template/apps/api/prisma/seed-templates.ts +154 -0
- package/template/apps/api/prisma/seed.ts +191 -0
- package/template/apps/api/src/env.ts +61 -0
- package/template/apps/api/src/index.ts +66 -0
- package/template/apps/api/src/lib/revalidate.ts +28 -0
- package/template/apps/api/src/lib/storage/azure.ts +25 -0
- package/template/apps/api/src/lib/storage/cloudinary.ts +42 -0
- package/template/apps/api/src/lib/storage/index.ts +25 -0
- package/template/apps/api/src/lib/storage/s3.ts +39 -0
- package/template/apps/api/src/lib/storage/types.ts +8 -0
- package/template/apps/api/src/middleware/auditLog.ts +32 -0
- package/template/apps/api/src/middleware/auth.ts +53 -0
- package/template/apps/api/src/middleware/rateLimit.ts +31 -0
- package/template/apps/api/src/middleware/securityHeaders.ts +10 -0
- package/template/apps/api/src/prisma.ts +3 -0
- package/template/apps/api/src/routes/audit.routes.ts +26 -0
- package/template/apps/api/src/routes/auth.routes.ts +46 -0
- package/template/apps/api/src/routes/backup.routes.ts +216 -0
- package/template/apps/api/src/routes/blocks.routes.ts +38 -0
- package/template/apps/api/src/routes/collections.routes.ts +185 -0
- package/template/apps/api/src/routes/forms.routes.ts +114 -0
- package/template/apps/api/src/routes/globalSections.routes.ts +43 -0
- package/template/apps/api/src/routes/media.routes.ts +77 -0
- package/template/apps/api/src/routes/pages.routes.ts +319 -0
- package/template/apps/api/src/routes/redirects.routes.ts +52 -0
- package/template/apps/api/src/routes/search.routes.ts +83 -0
- package/template/apps/api/src/routes/templates.routes.ts +41 -0
- package/template/apps/api/src/routes/theme.routes.ts +75 -0
- package/template/apps/api/src/routes/users.routes.ts +39 -0
- package/template/apps/api/tsconfig.json +11 -0
- package/template/apps/web/Dockerfile +26 -0
- package/template/apps/web/app/[[...slug]]/page.tsx +254 -0
- package/template/apps/web/app/admin/(protected)/audit/page.tsx +80 -0
- package/template/apps/web/app/admin/(protected)/collections/item/[itemId]/page.tsx +375 -0
- package/template/apps/web/app/admin/(protected)/collections/page.tsx +224 -0
- package/template/apps/web/app/admin/(protected)/forms/page.tsx +88 -0
- package/template/apps/web/app/admin/(protected)/layout.tsx +5 -0
- package/template/apps/web/app/admin/(protected)/media/page.tsx +138 -0
- package/template/apps/web/app/admin/(protected)/page.tsx +186 -0
- package/template/apps/web/app/admin/(protected)/pages/[id]/page.tsx +560 -0
- package/template/apps/web/app/admin/(protected)/settings/backup/page.tsx +97 -0
- package/template/apps/web/app/admin/(protected)/settings/global-sections/page.tsx +333 -0
- package/template/apps/web/app/admin/(protected)/settings/navigation/page.tsx +192 -0
- package/template/apps/web/app/admin/(protected)/settings/redirects/page.tsx +108 -0
- package/template/apps/web/app/admin/(protected)/settings/theme/page.tsx +239 -0
- package/template/apps/web/app/admin/(protected)/users/page.tsx +94 -0
- package/template/apps/web/app/admin/login/page.tsx +76 -0
- package/template/apps/web/app/api/revalidate/route.ts +34 -0
- package/template/apps/web/app/globals.css +120 -0
- package/template/apps/web/app/layout.tsx +39 -0
- package/template/apps/web/app/preview/[token]/page.tsx +70 -0
- package/template/apps/web/app/robots.ts +11 -0
- package/template/apps/web/app/sitemap.ts +27 -0
- package/template/apps/web/components/admin/AdminShell.tsx +104 -0
- package/template/apps/web/components/builder/BuilderCanvasNode.tsx +546 -0
- package/template/apps/web/components/builder/BuilderDeviceContext.tsx +13 -0
- package/template/apps/web/components/builder/Canvas.tsx +76 -0
- package/template/apps/web/components/builder/CanvasOverlayContext.tsx +22 -0
- package/template/apps/web/components/builder/ColorPickerField.tsx +47 -0
- package/template/apps/web/components/builder/DroppableChildren.tsx +50 -0
- package/template/apps/web/components/builder/FieldRenderer.tsx +277 -0
- package/template/apps/web/components/builder/IconRenderer.tsx +7 -0
- package/template/apps/web/components/builder/Inspector.tsx +956 -0
- package/template/apps/web/components/builder/JsonEditor.tsx +71 -0
- package/template/apps/web/components/builder/KeyValueListEditor.tsx +76 -0
- package/template/apps/web/components/builder/MediaPickerModal.tsx +95 -0
- package/template/apps/web/components/builder/PageSettingsModal.tsx +363 -0
- package/template/apps/web/components/builder/Palette.tsx +106 -0
- package/template/apps/web/components/builder/RichTextEditor.tsx +140 -0
- package/template/apps/web/components/renderer/CmsImage.tsx +46 -0
- package/template/apps/web/components/renderer/NodeBackgroundLayers.tsx +35 -0
- package/template/apps/web/components/renderer/PublicPageView.tsx +56 -0
- package/template/apps/web/components/renderer/Renderer.tsx +146 -0
- package/template/apps/web/components/renderer/RendererContext.tsx +20 -0
- package/template/apps/web/components/renderer/registry.tsx +87 -0
- package/template/apps/web/components/renderer/sections/Accordion.tsx +39 -0
- package/template/apps/web/components/renderer/sections/BeforeAfter.tsx +75 -0
- package/template/apps/web/components/renderer/sections/Button.tsx +42 -0
- package/template/apps/web/components/renderer/sections/ButtonGroup.tsx +22 -0
- package/template/apps/web/components/renderer/sections/CardGrid.tsx +66 -0
- package/template/apps/web/components/renderer/sections/CollectionList.tsx +84 -0
- package/template/apps/web/components/renderer/sections/Column.tsx +26 -0
- package/template/apps/web/components/renderer/sections/ContactForm.tsx +96 -0
- package/template/apps/web/components/renderer/sections/Container.tsx +44 -0
- package/template/apps/web/components/renderer/sections/ContentLinks.tsx +38 -0
- package/template/apps/web/components/renderer/sections/Countdown.tsx +69 -0
- package/template/apps/web/components/renderer/sections/Cta.tsx +15 -0
- package/template/apps/web/components/renderer/sections/Divider.tsx +26 -0
- package/template/apps/web/components/renderer/sections/EmbedBlock.tsx +43 -0
- package/template/apps/web/components/renderer/sections/Footer.tsx +117 -0
- package/template/apps/web/components/renderer/sections/Gallery.tsx +27 -0
- package/template/apps/web/components/renderer/sections/GoToTop.tsx +54 -0
- package/template/apps/web/components/renderer/sections/Grid.tsx +54 -0
- package/template/apps/web/components/renderer/sections/GridCell.tsx +33 -0
- package/template/apps/web/components/renderer/sections/Header.tsx +132 -0
- package/template/apps/web/components/renderer/sections/Hero.tsx +73 -0
- package/template/apps/web/components/renderer/sections/ImageBackgroundSection.tsx +20 -0
- package/template/apps/web/components/renderer/sections/ImageBlock.tsx +55 -0
- package/template/apps/web/components/renderer/sections/LanguageSwitcher.tsx +51 -0
- package/template/apps/web/components/renderer/sections/Logo.tsx +24 -0
- package/template/apps/web/components/renderer/sections/LogoCloud.tsx +67 -0
- package/template/apps/web/components/renderer/sections/MapEmbed.tsx +24 -0
- package/template/apps/web/components/renderer/sections/Marquee.tsx +45 -0
- package/template/apps/web/components/renderer/sections/NavLinks.tsx +69 -0
- package/template/apps/web/components/renderer/sections/NavZone.tsx +19 -0
- package/template/apps/web/components/renderer/sections/PricingTable.tsx +65 -0
- package/template/apps/web/components/renderer/sections/RichText.tsx +7 -0
- package/template/apps/web/components/renderer/sections/SiteSearch.tsx +67 -0
- package/template/apps/web/components/renderer/sections/Slider.tsx +81 -0
- package/template/apps/web/components/renderer/sections/Spacer.tsx +8 -0
- package/template/apps/web/components/renderer/sections/Stats.tsx +69 -0
- package/template/apps/web/components/renderer/sections/Tabs.tsx +59 -0
- package/template/apps/web/components/renderer/sections/TeamGrid.tsx +49 -0
- package/template/apps/web/components/renderer/sections/Testimonial.tsx +79 -0
- package/template/apps/web/components/renderer/sections/ThreeBackground.tsx +43 -0
- package/template/apps/web/components/renderer/sections/Timeline.tsx +29 -0
- package/template/apps/web/components/renderer/sections/VideoBackgroundSection.tsx +20 -0
- package/template/apps/web/components/renderer/sections/VideoEmbed.tsx +70 -0
- package/template/apps/web/components/renderer/sections/types.ts +7 -0
- package/template/apps/web/components/seo/JsonLd.tsx +111 -0
- package/template/apps/web/components/theme/AnalyticsScripts.tsx +68 -0
- package/template/apps/web/components/theme/DarkModeToggle.tsx +39 -0
- package/template/apps/web/components/theme/SiteDataContext.tsx +39 -0
- package/template/apps/web/components/theme/ThemeProvider.tsx +58 -0
- package/template/apps/web/eslint.config.mjs +16 -0
- package/template/apps/web/lib/api.ts +45 -0
- package/template/apps/web/lib/responsiveCss.ts +42 -0
- package/template/apps/web/lib/sanitizeHtml.ts +16 -0
- package/template/apps/web/lib/serverApi.ts +25 -0
- package/template/apps/web/lib/style.ts +73 -0
- package/template/apps/web/lib/threeBackgroundScene.ts +298 -0
- package/template/apps/web/lib/useCurrentUser.ts +35 -0
- package/template/apps/web/lib/useEntranceAnimation.ts +245 -0
- package/template/apps/web/next-env.d.ts +6 -0
- package/template/apps/web/next.config.mjs +32 -0
- package/template/apps/web/package.json +46 -0
- package/template/apps/web/postcss.config.mjs +6 -0
- package/template/apps/web/proxy.ts +23 -0
- package/template/apps/web/tailwind.config.ts +24 -0
- package/template/apps/web/tsconfig.json +41 -0
- package/template/docker-compose.yml +60 -0
- package/template/package.json +29 -0
- package/template/packages/shared/package.json +21 -0
- package/template/packages/shared/src/components.ts +1626 -0
- package/template/packages/shared/src/index.ts +9 -0
- package/template/packages/shared/src/schema/collections.ts +70 -0
- package/template/packages/shared/src/schema/fields.ts +26 -0
- package/template/packages/shared/src/schema/media.ts +13 -0
- package/template/packages/shared/src/schema/pageNode.ts +255 -0
- package/template/packages/shared/src/schema/templates.ts +20 -0
- package/template/packages/shared/src/schema/theme.ts +69 -0
- package/template/packages/shared/src/schema/user.ts +25 -0
- package/template/packages/shared/src/tree.test.ts +65 -0
- package/template/packages/shared/src/tree.ts +303 -0
- package/template/packages/shared/tsconfig.json +8 -0
- package/template/packages/shared/vitest.config.ts +8 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { CmsImage } from "../CmsImage";
|
|
3
|
+
import type { SectionProps } from "./types";
|
|
4
|
+
|
|
5
|
+
type Logo = { image?: string; name?: string; href?: string };
|
|
6
|
+
|
|
7
|
+
function LogoImage({ logo, grayscale }: { logo: Logo; grayscale: boolean }) {
|
|
8
|
+
if (!logo.image) return null;
|
|
9
|
+
const img = (
|
|
10
|
+
<div className="relative h-10 w-28 shrink-0">
|
|
11
|
+
<CmsImage
|
|
12
|
+
src={logo.image}
|
|
13
|
+
alt={logo.name ?? ""}
|
|
14
|
+
fill
|
|
15
|
+
className={`object-contain transition-all ${grayscale ? "grayscale opacity-60 hover:grayscale-0 hover:opacity-100" : ""}`}
|
|
16
|
+
/>
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
return logo.href ? (
|
|
20
|
+
<a href={logo.href} target="_blank" rel="noopener noreferrer">
|
|
21
|
+
{img}
|
|
22
|
+
</a>
|
|
23
|
+
) : (
|
|
24
|
+
img
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The static-grid sibling of Marquee — a plain, evenly spaced row/wrap of partner
|
|
29
|
+
// logos. The "scroll" layout reuses Marquee's own .pgcms-marquee CSS (globals.css)
|
|
30
|
+
// rather than duplicating the keyframe animation.
|
|
31
|
+
export function LogoCloud({ node }: SectionProps) {
|
|
32
|
+
const heading = node.props.heading as string;
|
|
33
|
+
const logos = (node.props.logos as Logo[]) ?? [];
|
|
34
|
+
const layout = (node.props.layout as string) || "grid";
|
|
35
|
+
const grayscale = node.props.grayscale !== false;
|
|
36
|
+
|
|
37
|
+
if (logos.length === 0) return null;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div>
|
|
41
|
+
{heading && <p className="text-sm font-medium uppercase tracking-wide opacity-60 text-center mb-6">{heading}</p>}
|
|
42
|
+
{layout === "scroll" ? (
|
|
43
|
+
<div className="pgcms-marquee">
|
|
44
|
+
<div
|
|
45
|
+
className="pgcms-marquee-track gap-12 pr-12"
|
|
46
|
+
data-pause-on-hover="true"
|
|
47
|
+
style={{ "--pgcms-marquee-duration": "24s", "--pgcms-marquee-direction": "normal" } as CSSProperties}
|
|
48
|
+
>
|
|
49
|
+
{[0, 1].map((copy) => (
|
|
50
|
+
<div key={copy} className="inline-flex items-center gap-12" aria-hidden={copy === 1}>
|
|
51
|
+
{logos.map((logo, i) => (
|
|
52
|
+
<LogoImage key={i} logo={logo} grayscale={grayscale} />
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
) : (
|
|
59
|
+
<div className="flex flex-wrap items-center justify-center gap-10">
|
|
60
|
+
{logos.map((logo, i) => (
|
|
61
|
+
<LogoImage key={i} logo={logo} grayscale={grayscale} />
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SectionProps } from "./types";
|
|
2
|
+
|
|
3
|
+
export function MapEmbed({ node }: SectionProps) {
|
|
4
|
+
const lat = Number(node.props.lat) || 0;
|
|
5
|
+
const lng = Number(node.props.lng) || 0;
|
|
6
|
+
const zoom = Number(node.props.zoom) || 13;
|
|
7
|
+
const customEmbedUrl = node.props.embedUrl as string;
|
|
8
|
+
|
|
9
|
+
const src =
|
|
10
|
+
customEmbedUrl ||
|
|
11
|
+
`https://www.openstreetmap.org/export/embed.html?bbox=${lng - 0.05}%2C${lat - 0.03}%2C${lng + 0.05}%2C${
|
|
12
|
+
lat + 0.03
|
|
13
|
+
}&layer=mapnik&marker=${lat}%2C${lng}&zoom=${zoom}`;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<iframe
|
|
17
|
+
src={src}
|
|
18
|
+
className="w-full h-full min-h-[inherit] border-0"
|
|
19
|
+
style={{ minHeight: "400px" }}
|
|
20
|
+
loading="lazy"
|
|
21
|
+
title="Map"
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { CmsImage } from "../CmsImage";
|
|
2
|
+
import type { SectionProps } from "./types";
|
|
3
|
+
|
|
4
|
+
type MarqueeItem = { text?: string; image?: string };
|
|
5
|
+
|
|
6
|
+
export function Marquee({ node }: SectionProps) {
|
|
7
|
+
const items = (node.props.items as MarqueeItem[]) ?? [];
|
|
8
|
+
const speed = Math.max(4, Number(node.props.speed) || 20);
|
|
9
|
+
const direction = (node.props.direction as string) === "right" ? "reverse" : "normal";
|
|
10
|
+
const pauseOnHover = node.props.pauseOnHover !== false;
|
|
11
|
+
|
|
12
|
+
if (items.length === 0) return null;
|
|
13
|
+
|
|
14
|
+
// Two copies of the item list; the track translates by -50% and loops seamlessly.
|
|
15
|
+
const copies = [0, 1];
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="pgcms-marquee">
|
|
19
|
+
<div
|
|
20
|
+
className="pgcms-marquee-track gap-12 pr-12"
|
|
21
|
+
data-pause-on-hover={pauseOnHover}
|
|
22
|
+
style={
|
|
23
|
+
{
|
|
24
|
+
"--pgcms-marquee-duration": `${speed}s`,
|
|
25
|
+
"--pgcms-marquee-direction": direction,
|
|
26
|
+
} as React.CSSProperties
|
|
27
|
+
}
|
|
28
|
+
>
|
|
29
|
+
{copies.map((copy) => (
|
|
30
|
+
<div key={copy} className="inline-flex items-center gap-12" aria-hidden={copy === 1}>
|
|
31
|
+
{items.map((item, i) =>
|
|
32
|
+
item.image ? (
|
|
33
|
+
<CmsImage key={i} src={item.image} alt={item.text ?? ""} width={160} height={40} className="h-10 w-auto opacity-80" style={{ height: "2.5rem", width: "auto" }} />
|
|
34
|
+
) : (
|
|
35
|
+
<span key={i} className="text-lg font-semibold opacity-70 whitespace-nowrap">
|
|
36
|
+
{item.text}
|
|
37
|
+
</span>
|
|
38
|
+
)
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { ChevronDown } from "lucide-react";
|
|
5
|
+
import { buildNavTree } from "@pgcms/shared";
|
|
6
|
+
import { useSiteData } from "@/components/theme/SiteDataContext";
|
|
7
|
+
import type { SectionProps } from "./types";
|
|
8
|
+
|
|
9
|
+
type CustomLink = { label: string; href: string };
|
|
10
|
+
|
|
11
|
+
export function NavLinks({ node }: SectionProps) {
|
|
12
|
+
const { navItems } = useSiteData();
|
|
13
|
+
const useSiteNavigation = node.props.useSiteNavigation !== false;
|
|
14
|
+
const customLinks = (node.props.links as CustomLink[]) ?? [];
|
|
15
|
+
|
|
16
|
+
if (!useSiteNavigation) {
|
|
17
|
+
if (customLinks.length === 0) return null;
|
|
18
|
+
return (
|
|
19
|
+
<nav className="flex items-center gap-6">
|
|
20
|
+
{customLinks.map((link, i) => (
|
|
21
|
+
<Link key={i} href={link.href} className="text-sm font-medium hover:opacity-70">
|
|
22
|
+
{link.label}
|
|
23
|
+
</Link>
|
|
24
|
+
))}
|
|
25
|
+
</nav>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const items = buildNavTree(navItems);
|
|
30
|
+
if (items.length === 0) return null;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<nav className="flex items-center gap-6">
|
|
34
|
+
{items.map((item) =>
|
|
35
|
+
item.children.length > 0 ? (
|
|
36
|
+
<div key={item.id} className="relative group">
|
|
37
|
+
<Link
|
|
38
|
+
href={item.href}
|
|
39
|
+
className="flex items-center gap-1 text-sm font-medium hover:opacity-70 py-2"
|
|
40
|
+
>
|
|
41
|
+
{item.label}
|
|
42
|
+
<ChevronDown size={14} />
|
|
43
|
+
</Link>
|
|
44
|
+
<div
|
|
45
|
+
className="absolute left-0 top-full min-w-[180px] rounded-md border bg-white text-slate-900 shadow-lg py-1.5 z-40
|
|
46
|
+
opacity-0 invisible translate-y-1 transition-all duration-150
|
|
47
|
+
group-hover:opacity-100 group-hover:visible group-hover:translate-y-0
|
|
48
|
+
group-focus-within:opacity-100 group-focus-within:visible group-focus-within:translate-y-0"
|
|
49
|
+
>
|
|
50
|
+
{item.children.map((child) => (
|
|
51
|
+
<Link
|
|
52
|
+
key={child.id}
|
|
53
|
+
href={child.href}
|
|
54
|
+
className="block px-4 py-2 text-sm hover:bg-slate-50 whitespace-nowrap"
|
|
55
|
+
>
|
|
56
|
+
{child.label}
|
|
57
|
+
</Link>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
) : (
|
|
62
|
+
<Link key={item.id} href={item.href} className="text-sm font-medium hover:opacity-70">
|
|
63
|
+
{item.label}
|
|
64
|
+
</Link>
|
|
65
|
+
)
|
|
66
|
+
)}
|
|
67
|
+
</nav>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import clsx from "clsx";
|
|
2
|
+
import type { SectionProps } from "./types";
|
|
3
|
+
|
|
4
|
+
export function NavZone({ node, children }: SectionProps) {
|
|
5
|
+
const zone = (node.props.zone as string) ?? "start";
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<div
|
|
9
|
+
className={clsx(
|
|
10
|
+
"flex items-center gap-4 flex-wrap min-w-0",
|
|
11
|
+
zone === "start" && "justify-start",
|
|
12
|
+
zone === "middle" && "flex-1 justify-center",
|
|
13
|
+
zone === "end" && "justify-end"
|
|
14
|
+
)}
|
|
15
|
+
>
|
|
16
|
+
{children}
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Check } from "lucide-react";
|
|
2
|
+
import { Button } from "./Button";
|
|
3
|
+
import type { SectionProps } from "./types";
|
|
4
|
+
|
|
5
|
+
type Tier = {
|
|
6
|
+
name: string;
|
|
7
|
+
price: string;
|
|
8
|
+
period?: string;
|
|
9
|
+
features?: string;
|
|
10
|
+
buttonText?: string;
|
|
11
|
+
buttonHref?: string;
|
|
12
|
+
highlighted?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function PricingTable({ node }: SectionProps) {
|
|
16
|
+
const heading = node.props.heading as string;
|
|
17
|
+
const tiers = (node.props.tiers as Tier[]) ?? [];
|
|
18
|
+
|
|
19
|
+
if (tiers.length === 0) return null;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div>
|
|
23
|
+
{heading && <h2 className="text-2xl md:text-3xl font-bold mb-8 text-center">{heading}</h2>}
|
|
24
|
+
<div className="grid gap-6" style={{ gridTemplateColumns: `repeat(${Math.min(4, tiers.length)}, minmax(0, 1fr))` }}>
|
|
25
|
+
{tiers.map((tier, i) => {
|
|
26
|
+
const features = (tier.features ?? "").split("\n").map((f) => f.trim()).filter(Boolean);
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
key={i}
|
|
30
|
+
className={`flex flex-col gap-4 rounded-xl border p-6 ${
|
|
31
|
+
tier.highlighted ? "border-theme-primary shadow-lg scale-105 bg-white" : "bg-white"
|
|
32
|
+
}`}
|
|
33
|
+
>
|
|
34
|
+
{tier.highlighted && (
|
|
35
|
+
<span className="self-start text-xs font-semibold uppercase tracking-wide text-theme-primary">Popular</span>
|
|
36
|
+
)}
|
|
37
|
+
<h3 className="text-lg font-semibold">{tier.name}</h3>
|
|
38
|
+
<div className="flex items-baseline gap-1">
|
|
39
|
+
<span className="text-3xl font-bold">{tier.price}</span>
|
|
40
|
+
{tier.period && <span className="text-sm opacity-60">{tier.period}</span>}
|
|
41
|
+
</div>
|
|
42
|
+
{features.length > 0 && (
|
|
43
|
+
<ul className="flex flex-col gap-2 text-sm flex-1">
|
|
44
|
+
{features.map((f, j) => (
|
|
45
|
+
<li key={j} className="flex items-start gap-2">
|
|
46
|
+
<Check size={16} className="text-theme-primary shrink-0 mt-0.5" />
|
|
47
|
+
<span className="opacity-80">{f}</span>
|
|
48
|
+
</li>
|
|
49
|
+
))}
|
|
50
|
+
</ul>
|
|
51
|
+
)}
|
|
52
|
+
{tier.buttonText && (
|
|
53
|
+
<Button
|
|
54
|
+
text={tier.buttonText}
|
|
55
|
+
href={tier.buttonHref || "#"}
|
|
56
|
+
variant={tier.highlighted ? "primary" : "outline"}
|
|
57
|
+
/>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
})}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { sanitizeRichHtml } from "@/lib/sanitizeHtml";
|
|
2
|
+
import type { SectionProps } from "./types";
|
|
3
|
+
|
|
4
|
+
export function RichText({ node }: SectionProps) {
|
|
5
|
+
const html = (node.props.html as string) ?? "";
|
|
6
|
+
return <div className="prose" dangerouslySetInnerHTML={{ __html: sanitizeRichHtml(html) }} />;
|
|
7
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback } from "react";
|
|
4
|
+
import { Search as SearchIcon } from "lucide-react";
|
|
5
|
+
import type { SectionProps } from "./types";
|
|
6
|
+
|
|
7
|
+
type SearchResult = { type: "page" | "collection"; title: string; url: string; excerpt: string | null };
|
|
8
|
+
|
|
9
|
+
export function SiteSearch({ node }: SectionProps) {
|
|
10
|
+
const heading = (node.props.heading as string) || "Search";
|
|
11
|
+
const placeholder = (node.props.placeholder as string) || "Search this site…";
|
|
12
|
+
const [query, setQuery] = useState("");
|
|
13
|
+
const [results, setResults] = useState<SearchResult[] | null>(null);
|
|
14
|
+
const [loading, setLoading] = useState(false);
|
|
15
|
+
|
|
16
|
+
const runSearch = useCallback(async (q: string) => {
|
|
17
|
+
if (q.trim().length < 2) {
|
|
18
|
+
setResults(null);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
setLoading(true);
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(`/api/search?q=${encodeURIComponent(q.trim())}`);
|
|
24
|
+
if (!res.ok) throw new Error("search failed");
|
|
25
|
+
const data = (await res.json()) as { results: SearchResult[] };
|
|
26
|
+
setResults(data.results);
|
|
27
|
+
} catch {
|
|
28
|
+
setResults([]);
|
|
29
|
+
} finally {
|
|
30
|
+
setLoading(false);
|
|
31
|
+
}
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="max-w-xl mx-auto w-full">
|
|
36
|
+
{heading && <h2 className="text-2xl font-bold mb-4 text-center">{heading}</h2>}
|
|
37
|
+
<div className="relative">
|
|
38
|
+
<SearchIcon size={18} className="absolute left-3 top-1/2 -translate-y-1/2 opacity-50" />
|
|
39
|
+
<input
|
|
40
|
+
type="search"
|
|
41
|
+
value={query}
|
|
42
|
+
onChange={(e) => {
|
|
43
|
+
setQuery(e.target.value);
|
|
44
|
+
void runSearch(e.target.value);
|
|
45
|
+
}}
|
|
46
|
+
placeholder={placeholder}
|
|
47
|
+
className="w-full border rounded-lg pl-10 pr-4 py-2.5 text-sm bg-white text-slate-900"
|
|
48
|
+
aria-label={placeholder}
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
{loading && <p className="text-sm text-center opacity-60 mt-3">Searching…</p>}
|
|
52
|
+
{results !== null && !loading && (
|
|
53
|
+
<ul className="mt-4 space-y-2">
|
|
54
|
+
{results.length === 0 && <li className="text-sm text-center opacity-60">No results found.</li>}
|
|
55
|
+
{results.map((r) => (
|
|
56
|
+
<li key={r.url}>
|
|
57
|
+
<a href={r.url} className="block border rounded-lg px-4 py-3 hover:bg-slate-50 transition-colors">
|
|
58
|
+
<div className="font-medium">{r.title}</div>
|
|
59
|
+
{r.excerpt && <div className="text-sm opacity-70 line-clamp-2 mt-0.5">{r.excerpt}</div>}
|
|
60
|
+
</a>
|
|
61
|
+
</li>
|
|
62
|
+
))}
|
|
63
|
+
</ul>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
5
|
+
import { Button } from "./Button";
|
|
6
|
+
import { CmsImage } from "../CmsImage";
|
|
7
|
+
import type { SectionProps } from "./types";
|
|
8
|
+
|
|
9
|
+
type Slide = { image: string; heading?: string; subheading?: string; buttonText?: string; buttonHref?: string };
|
|
10
|
+
|
|
11
|
+
export function Slider({ node }: SectionProps) {
|
|
12
|
+
const slides = (node.props.slides as Slide[]) ?? [];
|
|
13
|
+
const autoplay = node.props.autoplay !== false;
|
|
14
|
+
const intervalMs = Number(node.props.intervalMs) || 5000;
|
|
15
|
+
const [index, setIndex] = useState(0);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!autoplay || slides.length < 2) return;
|
|
19
|
+
const id = setInterval(() => setIndex((i) => (i + 1) % slides.length), intervalMs);
|
|
20
|
+
return () => clearInterval(id);
|
|
21
|
+
}, [autoplay, intervalMs, slides.length]);
|
|
22
|
+
|
|
23
|
+
if (slides.length === 0) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="h-64 flex items-center justify-center bg-slate-100 text-slate-400 text-sm">
|
|
26
|
+
No slides added yet
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const slide = slides[Math.min(index, slides.length - 1)];
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="relative w-full h-full min-h-[inherit] overflow-hidden">
|
|
35
|
+
{slide.image && <CmsImage src={slide.image} alt={slide.heading ?? ""} fill className="object-cover" />}
|
|
36
|
+
<div className="absolute inset-0 bg-black/40" />
|
|
37
|
+
<div className="relative z-10 flex flex-col items-center justify-center h-full min-h-[480px] gap-4 px-6 text-center">
|
|
38
|
+
{slide.heading && <h2 className="text-3xl md:text-4xl font-bold">{slide.heading}</h2>}
|
|
39
|
+
{slide.subheading && <p className="text-lg opacity-90">{slide.subheading}</p>}
|
|
40
|
+
{slide.buttonText && <Button text={slide.buttonText} href={slide.buttonHref || "#"} variant="primary" />}
|
|
41
|
+
</div>
|
|
42
|
+
{slides.length > 1 && (
|
|
43
|
+
<>
|
|
44
|
+
<button
|
|
45
|
+
onClick={(e) => {
|
|
46
|
+
e.stopPropagation();
|
|
47
|
+
setIndex((i) => (i - 1 + slides.length) % slides.length);
|
|
48
|
+
}}
|
|
49
|
+
className="absolute left-4 top-1/2 -translate-y-1/2 z-20 bg-white/20 hover:bg-white/40 rounded-full p-2"
|
|
50
|
+
aria-label="Previous slide"
|
|
51
|
+
>
|
|
52
|
+
<ChevronLeft className="text-white" />
|
|
53
|
+
</button>
|
|
54
|
+
<button
|
|
55
|
+
onClick={(e) => {
|
|
56
|
+
e.stopPropagation();
|
|
57
|
+
setIndex((i) => (i + 1) % slides.length);
|
|
58
|
+
}}
|
|
59
|
+
className="absolute right-4 top-1/2 -translate-y-1/2 z-20 bg-white/20 hover:bg-white/40 rounded-full p-2"
|
|
60
|
+
aria-label="Next slide"
|
|
61
|
+
>
|
|
62
|
+
<ChevronRight className="text-white" />
|
|
63
|
+
</button>
|
|
64
|
+
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20 flex gap-2">
|
|
65
|
+
{slides.map((_, i) => (
|
|
66
|
+
<button
|
|
67
|
+
key={i}
|
|
68
|
+
onClick={(e) => {
|
|
69
|
+
e.stopPropagation();
|
|
70
|
+
setIndex(i);
|
|
71
|
+
}}
|
|
72
|
+
className={`h-2 w-2 rounded-full ${i === index ? "bg-white" : "bg-white/40"}`}
|
|
73
|
+
aria-label={`Go to slide ${i + 1}`}
|
|
74
|
+
/>
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
</>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SectionProps } from "./types";
|
|
2
|
+
|
|
3
|
+
/** Pure vertical whitespace — a "break" between two sections that don't otherwise need
|
|
4
|
+
* padding of their own. Purely visual: no border, no background. */
|
|
5
|
+
export function Spacer({ node }: SectionProps) {
|
|
6
|
+
const height = (node.props.height as string) || "48px";
|
|
7
|
+
return <div style={{ height }} aria-hidden="true" />;
|
|
8
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import type { SectionProps } from "./types";
|
|
5
|
+
|
|
6
|
+
type StatItem = { value: string; label: string };
|
|
7
|
+
|
|
8
|
+
/** Splits "500+" -> {prefix:"", number:500, suffix:"+"}, or "$2M" -> {prefix:"$", number:2, suffix:"M"}.
|
|
9
|
+
* Falls back to a null number (no animation, just display the raw string) when unparseable. */
|
|
10
|
+
function parseValue(raw: string) {
|
|
11
|
+
const match = raw.match(/^([^\d]*)(\d+(?:\.\d+)?)([^\d]*)$/);
|
|
12
|
+
if (!match) return { prefix: "", number: null as number | null, suffix: raw };
|
|
13
|
+
return { prefix: match[1], number: Number(match[2]), suffix: match[3] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function StatValue({ raw }: { raw: string }) {
|
|
17
|
+
const { prefix, number, suffix } = parseValue(raw);
|
|
18
|
+
const ref = useRef<HTMLSpanElement>(null);
|
|
19
|
+
const [display, setDisplay] = useState(number === null ? raw : 0);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (number === null || !ref.current) return;
|
|
23
|
+
const el = ref.current;
|
|
24
|
+
const target = number;
|
|
25
|
+
const observer = new IntersectionObserver(
|
|
26
|
+
([entry]) => {
|
|
27
|
+
if (!entry.isIntersecting) return;
|
|
28
|
+
observer.disconnect();
|
|
29
|
+
const duration = 1200;
|
|
30
|
+
const start = performance.now();
|
|
31
|
+
function tick(now: number) {
|
|
32
|
+
const progress = Math.min(1, (now - start) / duration);
|
|
33
|
+
setDisplay(Math.round(target * (1 - Math.pow(1 - progress, 3))));
|
|
34
|
+
if (progress < 1) requestAnimationFrame(tick);
|
|
35
|
+
}
|
|
36
|
+
requestAnimationFrame(tick);
|
|
37
|
+
},
|
|
38
|
+
{ threshold: 0.4 }
|
|
39
|
+
);
|
|
40
|
+
observer.observe(el);
|
|
41
|
+
return () => observer.disconnect();
|
|
42
|
+
}, [number]);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<span ref={ref} className="text-4xl md:text-5xl font-bold tabular-nums">
|
|
46
|
+
{prefix}
|
|
47
|
+
{display}
|
|
48
|
+
{suffix}
|
|
49
|
+
</span>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function Stats({ node }: SectionProps) {
|
|
54
|
+
const items = (node.props.items as StatItem[]) ?? [];
|
|
55
|
+
const columns = Math.max(2, Math.min(4, Number(node.props.columns) || 3));
|
|
56
|
+
|
|
57
|
+
if (items.length === 0) return null;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="grid gap-8" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>
|
|
61
|
+
{items.map((item, i) => (
|
|
62
|
+
<div key={i} className="flex flex-col items-center gap-1">
|
|
63
|
+
<StatValue raw={item.value} />
|
|
64
|
+
<span className="text-sm opacity-75">{item.label}</span>
|
|
65
|
+
</div>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import gsap from "gsap";
|
|
5
|
+
import { sanitizeRichHtml } from "@/lib/sanitizeHtml";
|
|
6
|
+
import type { SectionProps } from "./types";
|
|
7
|
+
|
|
8
|
+
type TabItem = { label: string; content: string };
|
|
9
|
+
type Transition = "none" | "fade" | "slide";
|
|
10
|
+
|
|
11
|
+
export function Tabs({ node }: SectionProps) {
|
|
12
|
+
const items = (node.props.items as TabItem[]) ?? [];
|
|
13
|
+
const transition = ((node.props.tabTransition as Transition) || "fade") as Transition;
|
|
14
|
+
const [active, setActive] = useState(0);
|
|
15
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
16
|
+
const firstRender = useRef(true);
|
|
17
|
+
|
|
18
|
+
// Animate the panel in whenever the active tab changes — not an entrance animation
|
|
19
|
+
// (that's the universal per-section one from useEntranceAnimation), this is a
|
|
20
|
+
// click-triggered content-switch transition scoped to this component.
|
|
21
|
+
useLayoutEffect(() => {
|
|
22
|
+
if (firstRender.current) {
|
|
23
|
+
firstRender.current = false;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const el = panelRef.current;
|
|
27
|
+
if (!el || transition === "none") return;
|
|
28
|
+
const ctx = gsap.context(() => {
|
|
29
|
+
if (transition === "fade") {
|
|
30
|
+
gsap.fromTo(el, { opacity: 0 }, { opacity: 1, duration: 0.3, ease: "power2.out" });
|
|
31
|
+
} else {
|
|
32
|
+
gsap.fromTo(el, { opacity: 0, x: 16 }, { opacity: 1, x: 0, duration: 0.35, ease: "power2.out" });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
return () => ctx.revert();
|
|
36
|
+
}, [active, transition]);
|
|
37
|
+
|
|
38
|
+
if (items.length === 0) return null;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div>
|
|
42
|
+
<div className="flex items-center gap-1 border-b overflow-x-auto">
|
|
43
|
+
{items.map((tab, i) => (
|
|
44
|
+
<button
|
|
45
|
+
key={i}
|
|
46
|
+
onClick={() => setActive(i)}
|
|
47
|
+
className={
|
|
48
|
+
"px-4 py-2.5 text-sm font-medium whitespace-nowrap border-b-2 -mb-px transition-colors " +
|
|
49
|
+
(active === i ? "border-theme-primary text-theme-primary" : "border-transparent opacity-60 hover:opacity-100")
|
|
50
|
+
}
|
|
51
|
+
>
|
|
52
|
+
{tab.label}
|
|
53
|
+
</button>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
<div ref={panelRef} className="prose py-6" dangerouslySetInnerHTML={{ __html: sanitizeRichHtml(items[active]?.content ?? "") }} />
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Link2 } from "lucide-react";
|
|
2
|
+
import { CmsImage } from "../CmsImage";
|
|
3
|
+
import type { SectionProps } from "./types";
|
|
4
|
+
|
|
5
|
+
type Member = { photo?: string; name: string; role?: string; bio?: string; linkedinUrl?: string; twitterUrl?: string };
|
|
6
|
+
|
|
7
|
+
export function TeamGrid({ node }: SectionProps) {
|
|
8
|
+
const heading = node.props.heading as string;
|
|
9
|
+
const members = (node.props.members as Member[]) ?? [];
|
|
10
|
+
const columns = Math.max(1, Math.min(5, Number(node.props.columns) || 4));
|
|
11
|
+
|
|
12
|
+
if (members.length === 0) return null;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div>
|
|
16
|
+
{heading && <h2 className="text-2xl md:text-3xl font-bold mb-8 text-center">{heading}</h2>}
|
|
17
|
+
<div className="grid gap-8" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>
|
|
18
|
+
{members.map((member, i) => (
|
|
19
|
+
<div key={i} className="flex flex-col items-center gap-2">
|
|
20
|
+
{member.photo ? (
|
|
21
|
+
<div className="relative w-28 h-28 rounded-full overflow-hidden">
|
|
22
|
+
<CmsImage src={member.photo} alt={member.name} fill className="object-cover" />
|
|
23
|
+
</div>
|
|
24
|
+
) : (
|
|
25
|
+
<div className="w-28 h-28 rounded-full bg-slate-200" />
|
|
26
|
+
)}
|
|
27
|
+
<h3 className="font-semibold">{member.name}</h3>
|
|
28
|
+
{member.role && <p className="text-sm opacity-60">{member.role}</p>}
|
|
29
|
+
{member.bio && <p className="text-sm opacity-75">{member.bio}</p>}
|
|
30
|
+
{(member.linkedinUrl || member.twitterUrl) && (
|
|
31
|
+
<div className="flex gap-3 mt-1">
|
|
32
|
+
{member.linkedinUrl && (
|
|
33
|
+
<a href={member.linkedinUrl} target="_blank" rel="noopener noreferrer" aria-label={`${member.name} on LinkedIn`} className="opacity-60 hover:opacity-100">
|
|
34
|
+
<Link2 size={18} />
|
|
35
|
+
</a>
|
|
36
|
+
)}
|
|
37
|
+
{member.twitterUrl && (
|
|
38
|
+
<a href={member.twitterUrl} target="_blank" rel="noopener noreferrer" aria-label={`${member.name} on Twitter/X`} className="opacity-60 hover:opacity-100">
|
|
39
|
+
<Link2 size={18} />
|
|
40
|
+
</a>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|