create-vexcms 0.0.8 → 0.0.9

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.
Files changed (59) hide show
  1. package/package.json +1 -1
  2. package/templates/base-nextjs/convex/vex/globals.ts +82 -0
  3. package/templates/base-nextjs/convex/vex/versions.ts +66 -6
  4. package/templates/base-nextjs/package.json +7 -7
  5. package/templates/base-nextjs/src/app/admin/layout.tsx +29 -11
  6. package/templates/base-nextjs/src/db/constants/auth.ts +1 -3
  7. package/templates/base-nextjs/src/vexcms/access.ts +29 -13
  8. package/templates/base-nextjs/src/vexcms/collections/users.ts +2 -2
  9. package/templates/base-nextjs/vex.config.ts +2 -0
  10. package/templates/marketing-site/convex/footers.ts +14 -0
  11. package/templates/marketing-site/convex/headers.ts +14 -0
  12. package/templates/marketing-site/convex/siteSettings.ts +14 -0
  13. package/templates/marketing-site/convex/theme.ts +75 -0
  14. package/templates/marketing-site/public/favicons/favicon.ico +0 -0
  15. package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +74 -0
  16. package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +19 -51
  17. package/templates/marketing-site/src/app/(frontend)/layout.tsx +37 -0
  18. package/templates/marketing-site/src/app/(frontend)/page.tsx +24 -0
  19. package/templates/marketing-site/src/app/(frontend)/preview/[slug]/PreviewPageContent.tsx +55 -0
  20. package/templates/marketing-site/src/app/(frontend)/preview/[slug]/page.tsx +19 -63
  21. package/templates/marketing-site/src/app/(frontend)/preview/layout.tsx +18 -0
  22. package/templates/marketing-site/src/components/SiteFooter.tsx +28 -0
  23. package/templates/marketing-site/src/components/SiteHeader.tsx +28 -0
  24. package/templates/marketing-site/src/components/ThemeInjector.tsx +128 -0
  25. package/templates/marketing-site/src/components/ThemeStyle.tsx +104 -0
  26. package/templates/marketing-site/src/components/admin/IconPickerField.tsx +134 -0
  27. package/templates/marketing-site/src/components/motion-primitives/animated-group.tsx +138 -0
  28. package/templates/marketing-site/src/components/motion-primitives/text-effect.tsx +292 -0
  29. package/templates/marketing-site/src/lib/metadata.ts +104 -0
  30. package/templates/marketing-site/src/vexcms/access.ts +28 -0
  31. package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +36 -0
  32. package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +46 -0
  33. package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +70 -0
  34. package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +71 -0
  35. package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +61 -0
  36. package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +73 -0
  37. package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +65 -0
  38. package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +71 -0
  39. package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +65 -0
  40. package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +174 -0
  41. package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +52 -0
  42. package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +152 -0
  43. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +68 -0
  44. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +74 -0
  45. package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +154 -0
  46. package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +122 -0
  47. package/templates/marketing-site/src/vexcms/blocks/config.ts +40 -0
  48. package/templates/marketing-site/src/vexcms/blocks/constants.ts +9 -0
  49. package/templates/marketing-site/src/vexcms/blocks/index.ts +39 -0
  50. package/templates/marketing-site/src/vexcms/collections/footers.ts +11 -8
  51. package/templates/marketing-site/src/vexcms/collections/headers.ts +10 -14
  52. package/templates/marketing-site/src/vexcms/collections/index.ts +0 -1
  53. package/templates/marketing-site/src/vexcms/collections/pages.ts +30 -4
  54. package/templates/marketing-site/src/vexcms/collections/themes.ts +107 -14
  55. package/templates/marketing-site/src/vexcms/globals/index.ts +1 -0
  56. package/templates/marketing-site/src/vexcms/globals/siteSettings.ts +57 -0
  57. package/templates/marketing-site/vex.config.ts +5 -5
  58. package/templates/base-nextjs/src/auth/permissions.ts +0 -102
  59. package/templates/marketing-site/src/vexcms/collections/site_settings.ts +0 -31
@@ -0,0 +1,138 @@
1
+ "use client"
2
+
3
+ import { motion, type Variants } from "motion/react"
4
+ import React, { type ComponentPropsWithRef } from "react"
5
+
6
+ export type PresetType =
7
+ | "blur"
8
+ | "blur-slide"
9
+ | "bounce"
10
+ | "fade"
11
+ | "flip"
12
+ | "rotate"
13
+ | "scale"
14
+ | "slide"
15
+ | "swing"
16
+ | "zoom"
17
+
18
+ export type AnimatedGroupProps = ComponentPropsWithRef<"div"> & {
19
+ as?: React.ElementType
20
+ asChild?: React.ElementType
21
+ className?: string
22
+ preset?: PresetType
23
+ variants?: {
24
+ container?: Variants
25
+ item?: Variants
26
+ }
27
+ }
28
+
29
+ const defaultContainerVariants: Variants = {
30
+ visible: {
31
+ transition: {
32
+ staggerChildren: 0.1,
33
+ },
34
+ },
35
+ }
36
+
37
+ const defaultItemVariants: Variants = {
38
+ hidden: { opacity: 0 },
39
+ visible: { opacity: 1 },
40
+ }
41
+
42
+ const presetVariants: Record<PresetType, Variants> = {
43
+ "blur-slide": {
44
+ hidden: { filter: "blur(4px)", y: 20 },
45
+ visible: { filter: "blur(0px)", y: 0 },
46
+ },
47
+ slide: {
48
+ hidden: { y: 20 },
49
+ visible: { y: 0 },
50
+ },
51
+ blur: {
52
+ hidden: { filter: "blur(4px)" },
53
+ visible: { filter: "blur(0px)" },
54
+ },
55
+ bounce: {
56
+ hidden: { y: -50 },
57
+ visible: {
58
+ transition: { type: "spring", damping: 10, stiffness: 400 },
59
+ y: 0,
60
+ },
61
+ },
62
+ fade: {},
63
+ flip: {
64
+ hidden: { rotateX: -90 },
65
+ visible: {
66
+ rotateX: 0,
67
+ transition: { type: "spring", damping: 20, stiffness: 300 },
68
+ },
69
+ },
70
+ rotate: {
71
+ hidden: { rotate: -180 },
72
+ visible: {
73
+ rotate: 0,
74
+ transition: { type: "spring", damping: 15, stiffness: 200 },
75
+ },
76
+ },
77
+ scale: {
78
+ hidden: { scale: 0.8 },
79
+ visible: { scale: 1 },
80
+ },
81
+ swing: {
82
+ hidden: { rotate: -10 },
83
+ visible: {
84
+ rotate: 0,
85
+ transition: { type: "spring", damping: 8, stiffness: 300 },
86
+ },
87
+ },
88
+ zoom: {
89
+ hidden: { scale: 0.5 },
90
+ visible: {
91
+ scale: 1,
92
+ transition: { type: "spring", damping: 20, stiffness: 300 },
93
+ },
94
+ },
95
+ }
96
+
97
+ const addDefaultVariants = (variants: Variants) => ({
98
+ hidden: { ...defaultItemVariants.hidden, ...variants.hidden },
99
+ visible: { ...defaultItemVariants.visible, ...variants.visible },
100
+ })
101
+
102
+ function AnimatedGroup({
103
+ as = "div",
104
+ asChild = "div",
105
+ children,
106
+ className,
107
+ preset,
108
+ variants,
109
+ ...divProps
110
+ }: AnimatedGroupProps) {
111
+ const selectedVariants = {
112
+ container: addDefaultVariants(defaultContainerVariants),
113
+ item: addDefaultVariants(preset ? presetVariants[preset] : {}),
114
+ }
115
+ const containerVariants = variants?.container ?? selectedVariants.container
116
+ const itemVariants = variants?.item ?? selectedVariants.item
117
+
118
+ const MotionComponent = motion(as)
119
+ const MotionChild = motion(asChild)
120
+
121
+ return (
122
+ <MotionComponent
123
+ animate="visible"
124
+ className={className}
125
+ initial="hidden"
126
+ variants={containerVariants}
127
+ {...divProps}
128
+ >
129
+ {React.Children.map(children, (child, index) => (
130
+ <MotionChild key={index} variants={itemVariants}>
131
+ {child}
132
+ </MotionChild>
133
+ ))}
134
+ </MotionComponent>
135
+ )
136
+ }
137
+
138
+ export { AnimatedGroup }
@@ -0,0 +1,292 @@
1
+ "use client"
2
+
3
+ import {
4
+ AnimatePresence,
5
+ motion,
6
+ type TargetAndTransition,
7
+ type Transition,
8
+ type Variant,
9
+ type Variants,
10
+ } from "motion/react"
11
+ import React from "react"
12
+
13
+ import { cn } from "~/lib/utils"
14
+
15
+ export type PerType = "char" | "line" | "word"
16
+ export type PresetType = "blur" | "fade" | "fade-in-blur" | "scale" | "slide"
17
+
18
+ export type TextEffectProps = {
19
+ as?: keyof React.JSX.IntrinsicElements
20
+ children: string
21
+ className?: string
22
+ containerTransition?: Transition
23
+ delay?: number
24
+ onAnimationComplete?: () => void
25
+ onAnimationStart?: () => void
26
+ per?: PerType
27
+ preset?: PresetType
28
+ segmentTransition?: Transition
29
+ segmentWrapperClassName?: string
30
+ speedReveal?: number
31
+ speedSegment?: number
32
+ style?: React.CSSProperties
33
+ trigger?: boolean
34
+ variants?: {
35
+ container?: Variants
36
+ item?: Variants
37
+ }
38
+ }
39
+
40
+ const defaultStaggerTimes: Record<PerType, number> = {
41
+ char: 0.03,
42
+ line: 0.1,
43
+ word: 0.05,
44
+ }
45
+
46
+ const defaultContainerVariants: Variants = {
47
+ hidden: { opacity: 0 },
48
+ exit: {
49
+ transition: { staggerChildren: 0.05, staggerDirection: -1 },
50
+ },
51
+ visible: {
52
+ opacity: 1,
53
+ transition: {
54
+ staggerChildren: 0.05,
55
+ },
56
+ },
57
+ }
58
+
59
+ const defaultItemVariants: Variants = {
60
+ hidden: { opacity: 0 },
61
+ exit: { opacity: 0 },
62
+ visible: {
63
+ opacity: 1,
64
+ },
65
+ }
66
+
67
+ const presetVariants: Record<
68
+ PresetType,
69
+ { container: Variants; item: Variants }
70
+ > = {
71
+ slide: {
72
+ container: defaultContainerVariants,
73
+ item: {
74
+ hidden: { opacity: 0, y: 20 },
75
+ exit: { opacity: 0, y: 20 },
76
+ visible: { opacity: 1, y: 0 },
77
+ },
78
+ },
79
+ blur: {
80
+ container: defaultContainerVariants,
81
+ item: {
82
+ hidden: { filter: "blur(12px)", opacity: 0 },
83
+ exit: { filter: "blur(12px)", opacity: 0 },
84
+ visible: { filter: "blur(0px)", opacity: 1 },
85
+ },
86
+ },
87
+ fade: {
88
+ container: defaultContainerVariants,
89
+ item: {
90
+ hidden: { opacity: 0 },
91
+ exit: { opacity: 0 },
92
+ visible: { opacity: 1 },
93
+ },
94
+ },
95
+ "fade-in-blur": {
96
+ container: defaultContainerVariants,
97
+ item: {
98
+ hidden: { filter: "blur(12px)", opacity: 0, y: 20 },
99
+ exit: { filter: "blur(12px)", opacity: 0, y: 20 },
100
+ visible: { filter: "blur(0px)", opacity: 1, y: 0 },
101
+ },
102
+ },
103
+ scale: {
104
+ container: defaultContainerVariants,
105
+ item: {
106
+ hidden: { opacity: 0, scale: 0 },
107
+ exit: { opacity: 0, scale: 0 },
108
+ visible: { opacity: 1, scale: 1 },
109
+ },
110
+ },
111
+ }
112
+
113
+ const AnimationComponent: React.FC<{
114
+ per: "char" | "line" | "word"
115
+ segment: string
116
+ segmentWrapperClassName?: string
117
+ variants: Variants
118
+ }> = React.memo(({ per, segment, segmentWrapperClassName, variants }) => {
119
+ const content =
120
+ per === "line" ? (
121
+ <motion.span className="block" variants={variants}>
122
+ {segment}
123
+ </motion.span>
124
+ ) : per === "word" ? (
125
+ <motion.span
126
+ aria-hidden="true"
127
+ className="inline-block whitespace-pre"
128
+ variants={variants}
129
+ >
130
+ {segment}
131
+ </motion.span>
132
+ ) : (
133
+ <motion.span className="inline-block whitespace-pre">
134
+ {segment.split("").map((char, charIndex) => (
135
+ <motion.span
136
+ aria-hidden="true"
137
+ className="inline-block whitespace-pre"
138
+ key={`char-${charIndex}`}
139
+ variants={variants}
140
+ >
141
+ {char}
142
+ </motion.span>
143
+ ))}
144
+ </motion.span>
145
+ )
146
+
147
+ if (!segmentWrapperClassName) {
148
+ return content
149
+ }
150
+
151
+ const defaultWrapperClassName = per === "line" ? "block" : "inline-block"
152
+
153
+ return (
154
+ <span className={cn(defaultWrapperClassName, segmentWrapperClassName)}>
155
+ {content}
156
+ </span>
157
+ )
158
+ })
159
+
160
+ AnimationComponent.displayName = "AnimationComponent"
161
+
162
+ const splitText = (text: string, per: "char" | "line" | "word") => {
163
+ if (per === "line") return text.split("\n")
164
+ return text.split(/(\s+)/)
165
+ }
166
+
167
+ const hasTransition = (
168
+ variant: Variant,
169
+ ): variant is TargetAndTransition & { transition?: Transition } => {
170
+ return (
171
+ typeof variant === "object" && variant !== null && "transition" in variant
172
+ )
173
+ }
174
+
175
+ const createVariantsWithTransition = (
176
+ baseVariants: Variants,
177
+ transition?: Transition & { exit?: Transition },
178
+ ): Variants => {
179
+ if (!transition) return baseVariants
180
+
181
+ const { ...mainTransition } = transition
182
+
183
+ return {
184
+ ...baseVariants,
185
+ exit: {
186
+ ...baseVariants.exit,
187
+ transition: {
188
+ ...(hasTransition(baseVariants.exit)
189
+ ? baseVariants.exit.transition
190
+ : {}),
191
+ ...mainTransition,
192
+ staggerDirection: -1,
193
+ },
194
+ },
195
+ visible: {
196
+ ...baseVariants.visible,
197
+ transition: {
198
+ ...(hasTransition(baseVariants.visible)
199
+ ? baseVariants.visible.transition
200
+ : {}),
201
+ ...mainTransition,
202
+ },
203
+ },
204
+ }
205
+ }
206
+
207
+ export function TextEffect({
208
+ as = "p",
209
+ children,
210
+ className,
211
+ containerTransition,
212
+ delay = 0,
213
+ onAnimationComplete,
214
+ onAnimationStart,
215
+ per = "word",
216
+ preset = "fade",
217
+ segmentTransition,
218
+ segmentWrapperClassName,
219
+ speedReveal = 1,
220
+ speedSegment = 1,
221
+ style,
222
+ trigger = true,
223
+ variants,
224
+ }: TextEffectProps) {
225
+ const segments = splitText(children, per)
226
+ const MotionTag = motion[as as keyof typeof motion] as typeof motion.div
227
+
228
+ const baseVariants = preset
229
+ ? presetVariants[preset]
230
+ : { container: defaultContainerVariants, item: defaultItemVariants }
231
+
232
+ const stagger = defaultStaggerTimes[per] / speedReveal
233
+
234
+ const baseDuration = 0.3 / speedSegment
235
+
236
+ const customStagger = hasTransition(variants?.container?.visible ?? {})
237
+ ? (variants?.container?.visible as TargetAndTransition).transition
238
+ ?.staggerChildren
239
+ : undefined
240
+
241
+ const customDelay = hasTransition(variants?.container?.visible ?? {})
242
+ ? (variants?.container?.visible as TargetAndTransition).transition
243
+ ?.delayChildren
244
+ : undefined
245
+
246
+ const computedVariants = {
247
+ container: createVariantsWithTransition(
248
+ variants?.container ?? baseVariants.container,
249
+ {
250
+ delayChildren: customDelay ?? delay,
251
+ staggerChildren: customStagger ?? stagger,
252
+ ...containerTransition,
253
+ exit: {
254
+ staggerChildren: customStagger ?? stagger,
255
+ staggerDirection: -1,
256
+ },
257
+ },
258
+ ),
259
+ item: createVariantsWithTransition(variants?.item ?? baseVariants.item, {
260
+ duration: baseDuration,
261
+ ...segmentTransition,
262
+ }),
263
+ }
264
+
265
+ return (
266
+ <AnimatePresence mode="popLayout">
267
+ {trigger && (
268
+ <MotionTag
269
+ animate="visible"
270
+ className={className}
271
+ exit="exit"
272
+ initial="hidden"
273
+ onAnimationComplete={onAnimationComplete}
274
+ onAnimationStart={onAnimationStart}
275
+ style={style}
276
+ variants={computedVariants.container}
277
+ >
278
+ {per !== "line" ? <span className="sr-only">{children}</span> : null}
279
+ {segments.map((segment, index) => (
280
+ <AnimationComponent
281
+ key={`${per}-${index}-${segment}`}
282
+ per={per}
283
+ segment={segment}
284
+ segmentWrapperClassName={segmentWrapperClassName}
285
+ variants={computedVariants.item}
286
+ />
287
+ ))}
288
+ </MotionTag>
289
+ )}
290
+ </AnimatePresence>
291
+ )
292
+ }
@@ -0,0 +1,104 @@
1
+ import type { Metadata } from "next"
2
+
3
+ import { buildSiteMetadata } from "@vexcms/core"
4
+ import { fetchQuery } from "convex/nextjs"
5
+
6
+ import { api } from "@convex/_generated/api"
7
+ import { anyApi } from "convex/server"
8
+
9
+ /**
10
+ * Generate Next.js Metadata for a page.
11
+ * Fetches site settings and optionally a page document,
12
+ * merges with buildSiteMetadata, and returns Next.js Metadata format.
13
+ *
14
+ * @param props.slug - Optional page slug to fetch per-page SEO overrides
15
+ */
16
+ export async function generatePageMetadata(props: {
17
+ slug?: string
18
+ }): Promise<Metadata> {
19
+ try {
20
+ // Fetch site settings
21
+ const settings = (await fetchQuery(api.siteSettings.get)) as Record<
22
+ string,
23
+ unknown
24
+ > | null
25
+
26
+ if (!settings) {
27
+ return { title: "Untitled" }
28
+ }
29
+
30
+ // Resolve the ogImage URL from site settings (upload ID → URL)
31
+ let siteOgImageUrl: string | undefined
32
+ const ogImageId = settings.ogImage as string | undefined
33
+ if (ogImageId) {
34
+ try {
35
+ const mediaDoc = (await fetchQuery(
36
+ anyApi.vex.collections.getDocument,
37
+ {
38
+ collectionSlug: "media",
39
+ documentId: ogImageId,
40
+ }
41
+ )) as Record<string, unknown> | null
42
+ siteOgImageUrl = (mediaDoc?.url as string) || undefined
43
+ } catch {
44
+ // Media document not found — skip ogImage
45
+ }
46
+ }
47
+
48
+ // Fetch page data for per-page overrides
49
+ let pageData: Record<string, unknown> | null = null
50
+ if (props.slug) {
51
+ pageData = await fetchQuery(api.pages.getBySlug, {
52
+ slug: props.slug,
53
+ _vexDrafts: false,
54
+ })
55
+ }
56
+
57
+ // Build framework-agnostic metadata
58
+ const meta = buildSiteMetadata({
59
+ site: {
60
+ name: settings.name as string | undefined,
61
+ metaTitle: settings.metaTitle as string | undefined,
62
+ metaDescription: settings.metaDescription as string | undefined,
63
+ description: settings.description as string | undefined,
64
+ ogImage: siteOgImageUrl,
65
+ twitterHandle: settings.twitterHandle as string | undefined,
66
+ },
67
+ page: pageData
68
+ ? {
69
+ title: pageData.title as string | undefined,
70
+ metaTitle: pageData.metaTitle as string | undefined,
71
+ metaDescription: pageData.metaDescription as string | undefined,
72
+ ogImage: pageData.ogImage as string | undefined,
73
+ }
74
+ : undefined,
75
+ titleSuffix: " | Vex CMS",
76
+ })
77
+
78
+ // Map to Next.js Metadata
79
+ const metadata: Metadata = {
80
+ title: meta.title,
81
+ description: meta.description,
82
+ }
83
+
84
+ if (meta.ogImage) {
85
+ metadata.openGraph = {
86
+ title: meta.title,
87
+ description: meta.description,
88
+ images: [{ url: meta.ogImage }],
89
+ }
90
+ }
91
+
92
+ if (meta.twitterHandle) {
93
+ metadata.twitter = {
94
+ card: "summary_large_image",
95
+ site: meta.twitterHandle,
96
+ }
97
+ }
98
+
99
+ return metadata
100
+ } catch {
101
+ // Convex not available — return minimal metadata
102
+ return { title: "Vex CMS" }
103
+ }
104
+ }
@@ -0,0 +1,28 @@
1
+ import { defineAccess } from "@vexcms/core"
2
+
3
+ import { TABLE_SLUG_USERS, USER_ROLES } from "~/db/constants"
4
+ import { users } from "~/vexcms/collections"
5
+
6
+ export const access = defineAccess({
7
+ permissions: {
8
+ admin: {
9
+ admin: true,
10
+ user: true,
11
+ },
12
+ user: {
13
+ [TABLE_SLUG_USERS]: {
14
+ create: false,
15
+ delete: false,
16
+ read: ({ data: targetUser, user }) => {
17
+ return targetUser._id === user._id
18
+ },
19
+ update: ({ data: targetUser, user }) => {
20
+ return targetUser._id === user._id
21
+ },
22
+ },
23
+ },
24
+ },
25
+ resources: [users],
26
+ roles: [USER_ROLES.user, USER_ROLES.admin],
27
+ userCollection: users,
28
+ })
@@ -0,0 +1,36 @@
1
+ import { array, defineBlock, object, text } from "@vexcms/core"
2
+
3
+ import { BLOCK_SLUG_CTA } from "../constants"
4
+
5
+ export const ctaBlock = defineBlock({
6
+ slug: BLOCK_SLUG_CTA,
7
+ label: "Call to Action",
8
+ fields: {
9
+ heading: text({
10
+ label: "Heading",
11
+ required: true,
12
+ defaultValue: "Ready to build with Vex CMS?",
13
+ }),
14
+ subheading: text({
15
+ label: "Subheading",
16
+ defaultValue:
17
+ "Get started in minutes with create-vexcms. Real-time content management powered by Convex.",
18
+ }),
19
+ actions: array({
20
+ label: "Actions",
21
+ items: object({
22
+ fields: {
23
+ label: text({ label: "Button Label", required: true }),
24
+ href: text({ label: "Button Link", required: true }),
25
+ },
26
+ }),
27
+ defaultValue: [
28
+ { label: "Get Started", href: "/docs" },
29
+ { label: "View on GitHub", href: "https://github.com/vexcms/vex" },
30
+ ],
31
+ }),
32
+ },
33
+ admin: {
34
+ icon: "megaphone",
35
+ },
36
+ })
@@ -0,0 +1,46 @@
1
+ import type { BlockComponentProps } from "@vexcms/ui"
2
+
3
+ import Link from "next/link"
4
+
5
+ import { cn } from "~/lib/utils"
6
+ import { buttonVariants } from "~/components/ui/button"
7
+
8
+ export { ctaBlock } from "./config"
9
+
10
+ export default function CTABlock({ block }: BlockComponentProps) {
11
+ const { heading, subheading, actions } = block as unknown as {
12
+ heading: string
13
+ subheading?: string
14
+ actions?: Array<{ label: string; href: string }>
15
+ }
16
+
17
+ return (
18
+ <section className="py-16 md:py-32">
19
+ <div className="mx-auto max-w-5xl px-6">
20
+ <div className="text-center">
21
+ <h2 className="text-4xl font-semibold text-balance lg:text-5xl">
22
+ {heading}
23
+ </h2>
24
+ {subheading && <p className="mt-4">{subheading}</p>}
25
+
26
+ <div className="mt-12 flex flex-wrap justify-center gap-4">
27
+ {(actions ?? []).map((action, index) => (
28
+ <Link
29
+ key={index}
30
+ href={action.href ?? "/"}
31
+ className={cn(
32
+ buttonVariants({
33
+ size: "lg",
34
+ variant: index % 2 === 1 ? "outline" : "default",
35
+ })
36
+ )}
37
+ >
38
+ <span>{action.label}</span>
39
+ </Link>
40
+ ))}
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </section>
45
+ )
46
+ }