kofi-stack-template-generator 2.1.48 → 2.1.50
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/.turbo/turbo-build.log +5 -5
- package/dist/index.js +105 -725
- package/package.json +2 -2
- package/src/templates.generated.ts +24 -20
- package/templates/marketing/payload/public/favicon.ico +0 -0
- package/templates/marketing/payload/public/favicon.svg +6 -0
- package/templates/marketing/payload/public/logo-light.svg +6 -0
- package/templates/marketing/payload/public/logo.svg +6 -0
- package/templates/marketing/payload/src/Footer/Component.client.tsx +1 -1
- package/templates/marketing/payload/src/Footer/config.ts +1 -1
- package/templates/marketing/payload/src/Header/Component.client.tsx +1 -1
- package/templates/marketing/payload/src/Header/MobileMenu/index.tsx +1 -1
- package/templates/marketing/payload/src/app/(docs)/docs/[[...slug]]/page.tsx +6 -6
- package/templates/marketing/payload/src/app/(docs)/docs/layout.tsx +1 -1
- package/templates/marketing/payload/src/app/(docs)/layout.tsx +3 -3
- package/templates/marketing/payload/src/app/(frontend)/api/newsletter/route.ts +15 -15
- package/templates/marketing/payload/src/app/(frontend)/layout.tsx +17 -17
- package/templates/marketing/payload/src/app/(frontend)/posts/[slug]/BlogPostContent.tsx +5 -5
- package/templates/marketing/payload/src/app/(frontend)/posts/page.tsx +2 -2
- package/templates/marketing/payload/src/components/JsonLd/index.tsx +19 -19
- package/templates/marketing/payload/src/components/Logo/Logo.tsx +1 -1
- package/templates/marketing/payload/src/components/TableOfContents/index.tsx +3 -3
- package/templates/marketing/payload/src/endpoints/seed/home-static.ts +100 -600
- package/templates/marketing/payload/src/heros/ProductShowcase/AnimatedMockup.tsx +3 -3
- package/templates/marketing/payload/src/utilities/generateMeta.ts +16 -16
- package/templates/marketing/payload/src/utilities/mergeOpenGraph.ts +4 -4
package/dist/index.js
CHANGED
|
@@ -1417,16 +1417,20 @@ GCS_CREDENTIALS="{}" # JSON service account key
|
|
|
1417
1417
|
"marketing/payload/next.config.ts.hbs": "import { withPayload } from '@payloadcms/next/withPayload'\nimport type { NextConfig } from 'next'\n\nconst nextConfig: NextConfig = {\n transpilePackages: ['@repo/ui'],\n}\n\nexport default withPayload(nextConfig)\n",
|
|
1418
1418
|
"marketing/payload/package.json.hbs": '{\n "name": "@repo/marketing",\n "version": "0.1.0",\n "private": true,\n "type": "module",\n "scripts": {\n "build": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap && next build",\n "dev": "cross-env NODE_OPTIONS=--no-deprecation next dev --port 3000",\n "generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",\n "generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",\n "lint": "biome check .",\n "lint:fix": "biome check --write .",\n "payload": "cross-env NODE_OPTIONS=--no-deprecation payload",\n "start": "cross-env NODE_OPTIONS=--no-deprecation next start",\n "typecheck": "tsc --noEmit",\n "db:push": "payload migrate",\n "db:seed": "tsx src/seed.ts"\n },\n "dependencies": {\n "@payloadcms/admin-bar": "^3.0.0",\n "@payloadcms/db-postgres": "^3.0.0",\n "@payloadcms/email-resend": "^3.0.0",\n "@payloadcms/live-preview-react": "^3.0.0",\n "@payloadcms/next": "^3.0.0",\n "@payloadcms/plugin-form-builder": "^3.0.0",\n "@payloadcms/plugin-nested-docs": "^3.0.0",\n "@payloadcms/plugin-redirects": "^3.0.0",\n "@payloadcms/plugin-search": "^3.0.0",\n "@payloadcms/plugin-seo": "^3.0.0",\n "@payloadcms/richtext-lexical": "^3.0.0",\n "@payloadcms/storage-vercel-blob": "^3.0.0",\n "@payloadcms/ui": "^3.0.0",\n "@radix-ui/react-accordion": "^1.2.0",\n "@radix-ui/react-checkbox": "^1.0.4",\n "@radix-ui/react-label": "^2.0.2",\n "@radix-ui/react-select": "^2.0.0",\n "@radix-ui/react-slot": "^1.0.2",\n "@repo/ui": "workspace:*",\n "class-variance-authority": "^0.7.1",\n "clsx": "^2.1.1",\n "cross-env": "^7.0.3",\n "geist": "^1.3.0",\n "lucide-react": "^0.562.0",\n "next": "^15.4.10",\n "payload": "^3.70.0",\n "posthog-js": "^1.200.0",\n "prism-react-renderer": "^2.4.1",\n "react": "^19.0.0",\n "react-dom": "^19.0.0",\n "react-hook-form": "^7.71.1",\n "sharp": "^0.34.0",\n "stripe": "^17.7.0",\n "tailwind-merge": "^3.4.0"\n },\n "devDependencies": {\n "@repo/config-typescript": "workspace:*",\n "@tailwindcss/postcss": "^4.0.0",\n "@tailwindcss/typography": "^0.5.19",\n "@types/node": "^20.0.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "postcss": "^8.4.0",\n "sass": "^1.86.0",\n "tailwindcss": "^4.0.0",\n "tsx": "^4.0.0",\n "tw-animate-css": "^1.4.0",\n "typescript": "^5.0.0"\n }\n}\n',
|
|
1419
1419
|
"marketing/payload/postcss.config.mjs.hbs": "export default {\n plugins: {\n '@tailwindcss/postcss': {},\n },\n}\n",
|
|
1420
|
-
"marketing/payload/src/Footer/Component.client.tsx": '"use client"\n\nimport Link from "next/link"\nimport type React from "react"\nimport { useState } from "react"\n\nimport type { Footer, Page, Post } from "@/payload-types"\n\nimport { Logo } from "@/components/Logo/Logo"\nimport { Button } from "@/components/ui/button"\nimport { Input } from "@/components/ui/input"\nimport { ThemeSelector } from "@/providers/Theme/ThemeSelector"\nimport { cn } from "@/utilities/ui"\n\ninterface FooterClientProps {\n data: Footer | null\n}\n\n// Social icons as inline SVGs for flexibility\nconst SocialIcons = {\n twitter: (\n <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">\n <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />\n </svg>\n ),\n instagram: (\n <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">\n <path\n fillRule="evenodd"\n d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z"\n clipRule="evenodd"\n />\n </svg>\n ),\n linkedin: (\n <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">\n <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />\n </svg>\n ),\n github: (\n <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">\n <path\n fillRule="evenodd"\n d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"\n clipRule="evenodd"\n />\n </svg>\n ),\n youtube: (\n <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">\n <path\n fillRule="evenodd"\n d="M19.812 5.418c.861.23 1.538.907 1.768 1.768C21.998 8.746 22 12 22 12s0 3.255-.418 4.814a2.504 2.504 0 0 1-1.768 1.768c-1.56.419-7.814.419-7.814.419s-6.255 0-7.814-.419a2.505 2.505 0 0 1-1.768-1.768C2 15.255 2 12 2 12s0-3.255.417-4.814a2.507 2.507 0 0 1 1.768-1.768C5.744 5 11.998 5 11.998 5s6.255 0 7.814.418ZM15.194 12 10 15V9l5.194 3Z"\n clipRule="evenodd"\n />\n </svg>\n ),\n}\n\n// Helper function to get URL from link\nfunction getLinkUrl(link: {\n type?: ("reference" | "custom") | null\n reference?:\n | { relationTo: "pages"; value: number | Page }\n | { relationTo: "posts"; value: number | Post }\n | null\n url?: string | null\n}): string {\n if (link.type === "reference" && link.reference) {\n const value = link.reference.value\n if (typeof value === "object" && "slug" in value) {\n const prefix = link.reference.relationTo === "posts" ? "/posts" : ""\n return `${prefix}/${value.slug}`\n }\n }\n return link.url || "#"\n}\n\nexport const FooterClient: React.FC<FooterClientProps> = ({ data }) => {\n const currentYear = new Date().getFullYear()\n const [email, setEmail] = useState("")\n const [isSubmitting, setIsSubmitting] = useState(false)\n const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null)\n\n // Show minimal footer when database isn\'t initialized yet\n if (!data) {\n return (\n <footer className="mt-auto border-t border-border bg-background">\n <div className="container mx-auto flex flex-col items-center justify-between gap-4 px-4 py-6 sm:flex-row">\n <p className="text-sm text-muted-foreground">\n Site not configured yet. Visit <Link href="/admin" className="underline hover:text-foreground">/admin</Link> to set up.\n </p>\n <ThemeSelector />\n </div>\n </footer>\n )\n }\n\n const { columns, socialLinks, newsletter, copyrightText, bottomLinks } = data\n\n const handleNewsletterSubmit = async (e: React.FormEvent) => {\n e.preventDefault()\n if (!email || isSubmitting) return\n\n setIsSubmitting(true)\n setMessage(null)\n\n try {\n const response = await fetch("/api/newsletter", {\n method: "POST",\n headers: { "Content-Type": "application/json" },\n body: JSON.stringify({ email }),\n })\n\n const result = await response.json()\n\n if (result.success) {\n setMessage({ type: "success", text: result.message })\n setEmail("")\n } else {\n setMessage({ type: "error", text: result.message })\n }\n } catch {\n setMessage({ type: "error", text: "Something went wrong. Please try again." })\n } finally {\n setIsSubmitting(false)\n }\n }\n\n // Check if any social links are configured\n const hasSocialLinks =\n socialLinks?.twitter ||\n socialLinks?.instagram ||\n socialLinks?.linkedin ||\n socialLinks?.github ||\n socialLinks?.youtube\n\n return (\n <footer className="mt-auto border-t border-border bg-background">\n {/* Main Footer Content */}\n <div className="container mx-auto px-4 py-12 lg:py-16">\n <div className="grid grid-cols-2 gap-8 md:grid-cols-3 lg:grid-cols-6">\n {/* Link Columns */}\n {columns?.map((column) => (\n <div key={column.id || column.title} className="col-span-1">\n <h3 className="mb-4 text-sm font-medium text-foreground">{column.title}</h3>\n <ul className="space-y-3">\n {column.links?.map((linkItem) => (\n <li key={linkItem.id || linkItem.link.label}>\n <Link\n href={getLinkUrl(linkItem.link)}\n className="text-sm text-muted-foreground transition-colors hover:text-foreground"\n {...(linkItem.link.newTab\n ? { target: "_blank", rel: "noopener noreferrer" }\n : {})}\n >\n {linkItem.link.label}\n </Link>\n </li>\n ))}\n </ul>\n </div>\n ))}\n\n {/* Newsletter Column */}\n {newsletter?.enabled && (\n <div className="col-span-2 md:col-span-3 lg:col-span-2">\n <h3 className="mb-4 text-sm font-medium text-foreground">\n {newsletter.title || "Newsletter"}\n </h3>\n <p className="mb-4 text-sm text-muted-foreground">\n {newsletter.description || "Stay up to date with the latest updates."}\n </p>\n <form\n onSubmit={handleNewsletterSubmit}\n className="space-y-3"\n suppressHydrationWarning\n >\n <div className="flex gap-2">\n <Input\n type="email"\n placeholder={newsletter.placeholder || "Enter your email"}\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n className="flex-1"\n disabled={isSubmitting}\n required\n suppressHydrationWarning\n />\n <Button type="submit" disabled={isSubmitting} suppressHydrationWarning>\n {isSubmitting ? "..." : newsletter.buttonText || "Subscribe"}\n </Button>\n </div>\n {message && (\n <p\n className={cn(\n "text-sm",\n message.type === "success" ? "text-green-600" : "text-red-600",\n )}\n >\n {message.text}\n </p>\n )}\n </form>\n </div>\n )}\n </div>\n\n {/* Social Links */}\n {hasSocialLinks && (\n <div className="mt-10 flex items-center gap-4 border-t border-border pt-8">\n {socialLinks?.twitter && (\n <a\n href={socialLinks.twitter}\n target="_blank"\n rel="noopener noreferrer"\n className="text-muted-foreground transition-colors hover:text-foreground"\n aria-label="X (Twitter)"\n >\n {SocialIcons.twitter}\n </a>\n )}\n {socialLinks?.instagram && (\n <a\n href={socialLinks.instagram}\n target="_blank"\n rel="noopener noreferrer"\n className="text-muted-foreground transition-colors hover:text-foreground"\n aria-label="Instagram"\n >\n {SocialIcons.instagram}\n </a>\n )}\n {socialLinks?.linkedin && (\n <a\n href={socialLinks.linkedin}\n target="_blank"\n rel="noopener noreferrer"\n className="text-muted-foreground transition-colors hover:text-foreground"\n aria-label="LinkedIn"\n >\n {SocialIcons.linkedin}\n </a>\n )}\n {socialLinks?.github && (\n <a\n href={socialLinks.github}\n target="_blank"\n rel="noopener noreferrer"\n className="text-muted-foreground transition-colors hover:text-foreground"\n aria-label="GitHub"\n >\n {SocialIcons.github}\n </a>\n )}\n {socialLinks?.youtube && (\n <a\n href={socialLinks.youtube}\n target="_blank"\n rel="noopener noreferrer"\n className="text-muted-foreground transition-colors hover:text-foreground"\n aria-label="YouTube"\n >\n {SocialIcons.youtube}\n </a>\n )}\n </div>\n )}\n </div>\n\n {/* Bottom Bar */}\n <div className="border-t border-border">\n <div className="container mx-auto flex flex-col items-center justify-between gap-4 px-4 py-6 sm:flex-row">\n {/* Logo and Copyright */}\n <div className="flex items-center gap-4">\n <Link href="/" className="flex items-center">\n <Logo variant="auto" className="h-6 w-6" />\n </Link>\n <p className="text-sm text-muted-foreground">\n \xA9 {currentYear} {copyrightText || "DirectoryHub"}\n </p>\n </div>\n\n {/* Bottom Links and Theme Selector */}\n <div className="flex items-center gap-6">\n {bottomLinks?.map((linkItem) => (\n <Link\n key={linkItem.id || linkItem.link.label}\n href={getLinkUrl(linkItem.link)}\n className="text-sm text-muted-foreground transition-colors hover:text-foreground"\n {...(linkItem.link.newTab ? { target: "_blank", rel: "noopener noreferrer" } : {})}\n >\n {linkItem.link.label}\n </Link>\n ))}\n <ThemeSelector />\n </div>\n </div>\n </div>\n </footer>\n )\n}\n',
|
|
1420
|
+
"marketing/payload/public/favicon.ico": "AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////AP///wD///8A////AP///wD///8A////AP///wD///8A////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8A////AP///wD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA",
|
|
1421
|
+
"marketing/payload/public/favicon.svg": "PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHJ4PSI2IiBmaWxsPSIjMEYxRjNEIi8+CiAgPHBhdGggZD0iTTE2IDZDMTEuMDI5IDYgNyAxMC4wMjkgNyAxNUM3IDE2LjUgNy40IDE3LjkgOC4xIDE5LjFMMTYgMjZMMjMuOSAxOS4xQzI0LjYgMTcuOSAyNSAxNi41IDI1IDE1QzI1IDEwLjAyOSAyMC45NzEgNiAxNiA2WiIgZmlsbD0iIzNEQTlBMyIvPgogIDxwYXRoIGQ9Ik0xNiA5QzEyLjY4NiA5IDEwIDExLjY4NiAxMCAxNUMxMCAxNi4xIDEwLjMgMTcuMSAxMC44IDE4TDE2IDIzTDIxLjIgMThDMjEuNyAxNy4xIDIyIDE2LjEgMjIgMTVDMjIgMTEuNjg2IDE5LjMxNCA5IDE2IDlaIiBmaWxsPSIjMEYxRjNEIi8+CiAgPHRleHQgeD0iMTYiIHk9IjE4IiBmb250LWZhbWlseT0ic3lzdGVtLXVpLCAtYXBwbGUtc3lzdGVtLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjEwIiBmb250LXdlaWdodD0iNzAwIiBmaWxsPSJ3aGl0ZSIgdGV4dC1hbmNob3I9Im1pZGRsZSI+UzwvdGV4dD4KPC9zdmc+Cg==",
|
|
1422
|
+
"marketing/payload/public/logo-light.svg": "PHN2ZyB3aWR0aD0iMzQiIGhlaWdodD0iMzQiIHZpZXdCb3g9IjAgMCAzNCAzNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMzQiIGhlaWdodD0iMzQiIHJ4PSI4IiBmaWxsPSIjM0RBOUEzIi8+CiAgPHBhdGggZD0iTTE3IDdDMTIuMDI5IDcgOCAxMS4wMjkgOCAxNkM4IDE3LjUgOC40IDE4LjkgOS4xIDIwLjFMMTcgMjdMMjQuOSAyMC4xQzI1LjYgMTguOSAyNiAxNy41IDI2IDE2QzI2IDExLjAyOSAyMS45NzEgNyAxNyA3WiIgZmlsbD0id2hpdGUiLz4KICA8cGF0aCBkPSJNMTcgMTBDMTMuNjg2IDEwIDExIDEyLjY4NiAxMSAxNkMxMSAxNy4xIDExLjMgMTguMSAxMS44IDE5TDE3IDI0TDIyLjIgMTlDMjIuNyAxOC4xIDIzIDE3LjEgMjMgMTZDMjMgMTIuNjg2IDIwLjMxNCAxMCAxNyAxMFoiIGZpbGw9IiMzREE5QTMiLz4KICA8dGV4dCB4PSIxNyIgeT0iMTkiIGZvbnQtZmFtaWx5PSJzeXN0ZW0tdWksIC1hcHBsZS1zeXN0ZW0sIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTAiIGZvbnQtd2VpZ2h0PSI3MDAiIGZpbGw9IiMwRjFGM0QiIHRleHQtYW5jaG9yPSJtaWRkbGUiPlM8L3RleHQ+Cjwvc3ZnPgo=",
|
|
1423
|
+
"marketing/payload/public/logo.svg": "PHN2ZyB3aWR0aD0iMzQiIGhlaWdodD0iMzQiIHZpZXdCb3g9IjAgMCAzNCAzNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMzQiIGhlaWdodD0iMzQiIHJ4PSI4IiBmaWxsPSIjMEYxRjNEIi8+CiAgPHBhdGggZD0iTTE3IDdDMTIuMDI5IDcgOCAxMS4wMjkgOCAxNkM4IDE3LjUgOC40IDE4LjkgOS4xIDIwLjFMMTcgMjdMMjQuOSAyMC4xQzI1LjYgMTguOSAyNiAxNy41IDI2IDE2QzI2IDExLjAyOSAyMS45NzEgNyAxNyA3WiIgZmlsbD0iIzNEQTlBMyIvPgogIDxwYXRoIGQ9Ik0xNyAxMEMxMy42ODYgMTAgMTEgMTIuNjg2IDExIDE2QzExIDE3LjEgMTEuMyAxOC4xIDExLjggMTlMMTcgMjRMMjIuMiAxOUMyMi43IDE4LjEgMjMgMTcuMSAyMyAxNkMyMyAxMi42ODYgMjAuMzE0IDEwIDE3IDEwWiIgZmlsbD0iIzBGMUYzRCIvPgogIDx0ZXh0IHg9IjE3IiB5PSIxOSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwgLWFwcGxlLXN5c3RlbSwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIxMCIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0id2hpdGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiPlM8L3RleHQ+Cjwvc3ZnPgo=",
|
|
1424
|
+
"marketing/payload/src/Footer/Component.client.tsx": '"use client"\n\nimport Link from "next/link"\nimport type React from "react"\nimport { useState } from "react"\n\nimport type { Footer, Page, Post } from "@/payload-types"\n\nimport { Logo } from "@/components/Logo/Logo"\nimport { Button } from "@/components/ui/button"\nimport { Input } from "@/components/ui/input"\nimport { ThemeSelector } from "@/providers/Theme/ThemeSelector"\nimport { cn } from "@/utilities/ui"\n\ninterface FooterClientProps {\n data: Footer | null\n}\n\n// Social icons as inline SVGs for flexibility\nconst SocialIcons = {\n twitter: (\n <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">\n <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />\n </svg>\n ),\n instagram: (\n <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">\n <path\n fillRule="evenodd"\n d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z"\n clipRule="evenodd"\n />\n </svg>\n ),\n linkedin: (\n <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">\n <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />\n </svg>\n ),\n github: (\n <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">\n <path\n fillRule="evenodd"\n d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"\n clipRule="evenodd"\n />\n </svg>\n ),\n youtube: (\n <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">\n <path\n fillRule="evenodd"\n d="M19.812 5.418c.861.23 1.538.907 1.768 1.768C21.998 8.746 22 12 22 12s0 3.255-.418 4.814a2.504 2.504 0 0 1-1.768 1.768c-1.56.419-7.814.419-7.814.419s-6.255 0-7.814-.419a2.505 2.505 0 0 1-1.768-1.768C2 15.255 2 12 2 12s0-3.255.417-4.814a2.507 2.507 0 0 1 1.768-1.768C5.744 5 11.998 5 11.998 5s6.255 0 7.814.418ZM15.194 12 10 15V9l5.194 3Z"\n clipRule="evenodd"\n />\n </svg>\n ),\n}\n\n// Helper function to get URL from link\nfunction getLinkUrl(link: {\n type?: ("reference" | "custom") | null\n reference?:\n | { relationTo: "pages"; value: number | Page }\n | { relationTo: "posts"; value: number | Post }\n | null\n url?: string | null\n}): string {\n if (link.type === "reference" && link.reference) {\n const value = link.reference.value\n if (typeof value === "object" && "slug" in value) {\n const prefix = link.reference.relationTo === "posts" ? "/posts" : ""\n return `${prefix}/${value.slug}`\n }\n }\n return link.url || "#"\n}\n\nexport const FooterClient: React.FC<FooterClientProps> = ({ data }) => {\n const currentYear = new Date().getFullYear()\n const [email, setEmail] = useState("")\n const [isSubmitting, setIsSubmitting] = useState(false)\n const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null)\n\n // Show minimal footer when database isn\'t initialized yet\n if (!data) {\n return (\n <footer className="mt-auto border-t border-border bg-background">\n <div className="container mx-auto flex flex-col items-center justify-between gap-4 px-4 py-6 sm:flex-row">\n <p className="text-sm text-muted-foreground">\n Site not configured yet. Visit <Link href="/admin" className="underline hover:text-foreground">/admin</Link> to set up.\n </p>\n <ThemeSelector />\n </div>\n </footer>\n )\n }\n\n const { columns, socialLinks, newsletter, copyrightText, bottomLinks } = data\n\n const handleNewsletterSubmit = async (e: React.FormEvent) => {\n e.preventDefault()\n if (!email || isSubmitting) return\n\n setIsSubmitting(true)\n setMessage(null)\n\n try {\n const response = await fetch("/api/newsletter", {\n method: "POST",\n headers: { "Content-Type": "application/json" },\n body: JSON.stringify({ email }),\n })\n\n const result = await response.json()\n\n if (result.success) {\n setMessage({ type: "success", text: result.message })\n setEmail("")\n } else {\n setMessage({ type: "error", text: result.message })\n }\n } catch {\n setMessage({ type: "error", text: "Something went wrong. Please try again." })\n } finally {\n setIsSubmitting(false)\n }\n }\n\n // Check if any social links are configured\n const hasSocialLinks =\n socialLinks?.twitter ||\n socialLinks?.instagram ||\n socialLinks?.linkedin ||\n socialLinks?.github ||\n socialLinks?.youtube\n\n return (\n <footer className="mt-auto border-t border-border bg-background">\n {/* Main Footer Content */}\n <div className="container mx-auto px-4 py-12 lg:py-16">\n <div className="grid grid-cols-2 gap-8 md:grid-cols-3 lg:grid-cols-6">\n {/* Link Columns */}\n {columns?.map((column) => (\n <div key={column.id || column.title} className="col-span-1">\n <h3 className="mb-4 text-sm font-medium text-foreground">{column.title}</h3>\n <ul className="space-y-3">\n {column.links?.map((linkItem) => (\n <li key={linkItem.id || linkItem.link.label}>\n <Link\n href={getLinkUrl(linkItem.link)}\n className="text-sm text-muted-foreground transition-colors hover:text-foreground"\n {...(linkItem.link.newTab\n ? { target: "_blank", rel: "noopener noreferrer" }\n : {})}\n >\n {linkItem.link.label}\n </Link>\n </li>\n ))}\n </ul>\n </div>\n ))}\n\n {/* Newsletter Column */}\n {newsletter?.enabled && (\n <div className="col-span-2 md:col-span-3 lg:col-span-2">\n <h3 className="mb-4 text-sm font-medium text-foreground">\n {newsletter.title || "Newsletter"}\n </h3>\n <p className="mb-4 text-sm text-muted-foreground">\n {newsletter.description || "Stay up to date with the latest updates."}\n </p>\n <form\n onSubmit={handleNewsletterSubmit}\n className="space-y-3"\n suppressHydrationWarning\n >\n <div className="flex gap-2">\n <Input\n type="email"\n placeholder={newsletter.placeholder || "Enter your email"}\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n className="flex-1"\n disabled={isSubmitting}\n required\n suppressHydrationWarning\n />\n <Button type="submit" disabled={isSubmitting} suppressHydrationWarning>\n {isSubmitting ? "..." : newsletter.buttonText || "Subscribe"}\n </Button>\n </div>\n {message && (\n <p\n className={cn(\n "text-sm",\n message.type === "success" ? "text-green-600" : "text-red-600",\n )}\n >\n {message.text}\n </p>\n )}\n </form>\n </div>\n )}\n </div>\n\n {/* Social Links */}\n {hasSocialLinks && (\n <div className="mt-10 flex items-center gap-4 border-t border-border pt-8">\n {socialLinks?.twitter && (\n <a\n href={socialLinks.twitter}\n target="_blank"\n rel="noopener noreferrer"\n className="text-muted-foreground transition-colors hover:text-foreground"\n aria-label="X (Twitter)"\n >\n {SocialIcons.twitter}\n </a>\n )}\n {socialLinks?.instagram && (\n <a\n href={socialLinks.instagram}\n target="_blank"\n rel="noopener noreferrer"\n className="text-muted-foreground transition-colors hover:text-foreground"\n aria-label="Instagram"\n >\n {SocialIcons.instagram}\n </a>\n )}\n {socialLinks?.linkedin && (\n <a\n href={socialLinks.linkedin}\n target="_blank"\n rel="noopener noreferrer"\n className="text-muted-foreground transition-colors hover:text-foreground"\n aria-label="LinkedIn"\n >\n {SocialIcons.linkedin}\n </a>\n )}\n {socialLinks?.github && (\n <a\n href={socialLinks.github}\n target="_blank"\n rel="noopener noreferrer"\n className="text-muted-foreground transition-colors hover:text-foreground"\n aria-label="GitHub"\n >\n {SocialIcons.github}\n </a>\n )}\n {socialLinks?.youtube && (\n <a\n href={socialLinks.youtube}\n target="_blank"\n rel="noopener noreferrer"\n className="text-muted-foreground transition-colors hover:text-foreground"\n aria-label="YouTube"\n >\n {SocialIcons.youtube}\n </a>\n )}\n </div>\n )}\n </div>\n\n {/* Bottom Bar */}\n <div className="border-t border-border">\n <div className="container mx-auto flex flex-col items-center justify-between gap-4 px-4 py-6 sm:flex-row">\n {/* Logo and Copyright */}\n <div className="flex items-center gap-4">\n <Link href="/" className="flex items-center">\n <Logo variant="auto" className="h-6 w-6" />\n </Link>\n <p className="text-sm text-muted-foreground">\n \xA9 {currentYear} {copyrightText || "SaaSify"}\n </p>\n </div>\n\n {/* Bottom Links and Theme Selector */}\n <div className="flex items-center gap-6">\n {bottomLinks?.map((linkItem) => (\n <Link\n key={linkItem.id || linkItem.link.label}\n href={getLinkUrl(linkItem.link)}\n className="text-sm text-muted-foreground transition-colors hover:text-foreground"\n {...(linkItem.link.newTab ? { target: "_blank", rel: "noopener noreferrer" } : {})}\n >\n {linkItem.link.label}\n </Link>\n ))}\n <ThemeSelector />\n </div>\n </div>\n </div>\n </footer>\n )\n}\n',
|
|
1421
1425
|
"marketing/payload/src/Footer/Component.tsx": 'import { getCachedGlobal } from "@/utilities/getGlobals"\n\nimport type { Footer as FooterType } from "@/payload-types"\n\nimport { FooterClient } from "./Component.client"\n\nexport async function Footer() {\n const footerData = (await getCachedGlobal("footer", 1)()) as FooterType | null\n\n return <FooterClient data={footerData} />\n}\n',
|
|
1422
1426
|
"marketing/payload/src/Footer/RowLabel.tsx": '"use client"\nimport type { Footer } from "@/payload-types"\nimport { type RowLabelProps, useRowLabel } from "@payloadcms/ui"\n\ntype FooterLink = NonNullable<NonNullable<Footer["columns"]>[number]["links"]>[number]\n\nexport const RowLabel: React.FC<RowLabelProps> = () => {\n const data = useRowLabel<FooterLink>()\n\n const label = data?.data?.link?.label\n ? `Link ${data.rowNumber !== undefined ? data.rowNumber + 1 : ""}: ${data?.data?.link?.label}`\n : "Row"\n\n return <div>{label}</div>\n}\n',
|
|
1423
|
-
"marketing/payload/src/Footer/config.ts": 'import type { GlobalConfig } from "payload"\n\nimport { link } from "@/fields/link"\nimport { revalidateFooter } from "./hooks/revalidateFooter"\n\nexport const Footer: GlobalConfig = {\n slug: "footer",\n access: {\n read: () => true,\n },\n fields: [\n // Link Columns - organized groups of links\n {\n name: "columns",\n type: "array",\n label: "Link Columns",\n maxRows: 5,\n admin: {\n initCollapsed: true,\n description: "Add columns of links (e.g., Solutions, Resources, Company)",\n },\n fields: [\n {\n name: "title",\n type: "text",\n required: true,\n label: "Column Title",\n },\n {\n name: "links",\n type: "array",\n label: "Links",\n maxRows: 10,\n fields: [\n link({\n appearances: false,\n }),\n ],\n admin: {\n initCollapsed: true,\n },\n },\n ],\n },\n // Social Links\n {\n name: "socialLinks",\n type: "group",\n label: "Social Links",\n admin: {\n description: "Add your social media profile URLs",\n },\n fields: [\n {\n name: "twitter",\n type: "text",\n label: "X (Twitter)",\n admin: {\n placeholder: "https://x.com/yourhandle",\n },\n },\n {\n name: "instagram",\n type: "text",\n label: "Instagram",\n admin: {\n placeholder: "https://instagram.com/yourhandle",\n },\n },\n {\n name: "linkedin",\n type: "text",\n label: "LinkedIn",\n admin: {\n placeholder: "https://linkedin.com/company/yourcompany",\n },\n },\n {\n name: "github",\n type: "text",\n label: "GitHub",\n admin: {\n placeholder: "https://github.com/yourorg",\n },\n },\n {\n name: "youtube",\n type: "text",\n label: "YouTube",\n admin: {\n placeholder: "https://youtube.com/@yourchannel",\n },\n },\n ],\n },\n // Newsletter Section\n {\n name: "newsletter",\n type: "group",\n label: "Newsletter",\n admin: {\n description: "Configure the newsletter signup section",\n },\n fields: [\n {\n name: "enabled",\n type: "checkbox",\n label: "Enable Newsletter Signup",\n defaultValue: true,\n },\n {\n name: "title",\n type: "text",\n label: "Title",\n defaultValue: "Newsletter",\n admin: {\n condition: (_, siblingData) => siblingData?.enabled,\n },\n },\n {\n name: "description",\n type: "textarea",\n label: "Description",\n defaultValue: "Stay up to date with the latest updates and news.",\n admin: {\n condition: (_, siblingData) => siblingData?.enabled,\n },\n },\n {\n name: "buttonText",\n type: "text",\n label: "Button Text",\n defaultValue: "Subscribe",\n admin: {\n condition: (_, siblingData) => siblingData?.enabled,\n },\n },\n {\n name: "placeholder",\n type: "text",\n label: "Email Placeholder",\n defaultValue: "Enter your email",\n admin: {\n condition: (_, siblingData) => siblingData?.enabled,\n },\n },\n ],\n },\n // Copyright and Bottom Bar\n {\n name: "copyrightText",\n type: "text",\n label: "Copyright Text",\n defaultValue: "
|
|
1427
|
+
"marketing/payload/src/Footer/config.ts": 'import type { GlobalConfig } from "payload"\n\nimport { link } from "@/fields/link"\nimport { revalidateFooter } from "./hooks/revalidateFooter"\n\nexport const Footer: GlobalConfig = {\n slug: "footer",\n access: {\n read: () => true,\n },\n fields: [\n // Link Columns - organized groups of links\n {\n name: "columns",\n type: "array",\n label: "Link Columns",\n maxRows: 5,\n admin: {\n initCollapsed: true,\n description: "Add columns of links (e.g., Solutions, Resources, Company)",\n },\n fields: [\n {\n name: "title",\n type: "text",\n required: true,\n label: "Column Title",\n },\n {\n name: "links",\n type: "array",\n label: "Links",\n maxRows: 10,\n fields: [\n link({\n appearances: false,\n }),\n ],\n admin: {\n initCollapsed: true,\n },\n },\n ],\n },\n // Social Links\n {\n name: "socialLinks",\n type: "group",\n label: "Social Links",\n admin: {\n description: "Add your social media profile URLs",\n },\n fields: [\n {\n name: "twitter",\n type: "text",\n label: "X (Twitter)",\n admin: {\n placeholder: "https://x.com/yourhandle",\n },\n },\n {\n name: "instagram",\n type: "text",\n label: "Instagram",\n admin: {\n placeholder: "https://instagram.com/yourhandle",\n },\n },\n {\n name: "linkedin",\n type: "text",\n label: "LinkedIn",\n admin: {\n placeholder: "https://linkedin.com/company/yourcompany",\n },\n },\n {\n name: "github",\n type: "text",\n label: "GitHub",\n admin: {\n placeholder: "https://github.com/yourorg",\n },\n },\n {\n name: "youtube",\n type: "text",\n label: "YouTube",\n admin: {\n placeholder: "https://youtube.com/@yourchannel",\n },\n },\n ],\n },\n // Newsletter Section\n {\n name: "newsletter",\n type: "group",\n label: "Newsletter",\n admin: {\n description: "Configure the newsletter signup section",\n },\n fields: [\n {\n name: "enabled",\n type: "checkbox",\n label: "Enable Newsletter Signup",\n defaultValue: true,\n },\n {\n name: "title",\n type: "text",\n label: "Title",\n defaultValue: "Newsletter",\n admin: {\n condition: (_, siblingData) => siblingData?.enabled,\n },\n },\n {\n name: "description",\n type: "textarea",\n label: "Description",\n defaultValue: "Stay up to date with the latest updates and news.",\n admin: {\n condition: (_, siblingData) => siblingData?.enabled,\n },\n },\n {\n name: "buttonText",\n type: "text",\n label: "Button Text",\n defaultValue: "Subscribe",\n admin: {\n condition: (_, siblingData) => siblingData?.enabled,\n },\n },\n {\n name: "placeholder",\n type: "text",\n label: "Email Placeholder",\n defaultValue: "Enter your email",\n admin: {\n condition: (_, siblingData) => siblingData?.enabled,\n },\n },\n ],\n },\n // Copyright and Bottom Bar\n {\n name: "copyrightText",\n type: "text",\n label: "Copyright Text",\n defaultValue: "SaaSify",\n admin: {\n description: "Company name for copyright (year is added automatically)",\n },\n },\n {\n name: "bottomLinks",\n type: "array",\n label: "Bottom Bar Links",\n maxRows: 4,\n admin: {\n description: "Links shown in the bottom bar (e.g., Contact Support, Privacy Policy)",\n initCollapsed: true,\n },\n fields: [\n link({\n appearances: false,\n }),\n ],\n },\n ],\n hooks: {\n afterChange: [revalidateFooter],\n },\n}\n',
|
|
1424
1428
|
"marketing/payload/src/Footer/hooks/revalidateFooter.ts": 'import type { GlobalAfterChangeHook } from "payload"\n\nimport { revalidateTag } from "next/cache"\n\nexport const revalidateFooter: GlobalAfterChangeHook = ({ doc, req: { payload, context } }) => {\n if (!context.disableRevalidate) {\n payload.logger.info("Revalidating footer")\n\n revalidateTag("global_footer")\n }\n\n return doc\n}\n',
|
|
1425
|
-
"marketing/payload/src/Header/Component.client.tsx": '"use client"\nimport { useHeaderTheme } from "@/providers/HeaderTheme"\nimport Link from "next/link"\nimport type React from "react"\nimport { useEffect, useMemo, useState } from "react"\n\nimport type { Header, Page, Post } from "@/payload-types"\n\nimport { Logo } from "@/components/Logo/Logo"\nimport { Button } from "@/components/ui/button"\nimport { MobileMenu } from "./MobileMenu"\nimport { HeaderNav } from "./Nav"\n\ninterface HeaderClientProps {\n data: Header | null\n}\n\n// Helper to get href from link data\nconst getLinkHref = (link: {\n type?: "custom" | "reference" | null\n reference?: {\n relationTo: "pages" | "posts"\n value: Page | Post | string | number\n } | null\n url?: string | null\n}): string => {\n if (link.type === "reference" && link.reference) {\n const { relationTo, value } = link.reference\n if (typeof value === "object" && value.slug) {\n return relationTo === "pages" ? `/${value.slug}` : `/${relationTo}/${value.slug}`\n }\n }\n return link.url || "#"\n}\n\nexport const HeaderClient: React.FC<HeaderClientProps> = ({ data }) => {\n /* Storing the value in a useState to avoid hydration errors */\n const [theme, setTheme] = useState<string | null>(null)\n const { headerTheme, setHeaderTheme } = useHeaderTheme()\n\n useEffect(() => {\n setHeaderTheme(null)\n }, [setHeaderTheme])\n\n useEffect(() => {\n if (headerTheme && headerTheme !== theme) setTheme(headerTheme)\n }, [headerTheme, theme])\n\n // Get the primary CTA (first right-positioned button item) for mobile view\n const primaryCta = useMemo(() => {\n const rightButtonItems = data?.navItems?.filter(\n (item) =>\n (item.position || "left") === "right" &&\n item.type === "link" &&\n (item.appearance || "button") === "button",\n )\n return rightButtonItems?.[0]\n }, [data?.navItems])\n\n // Show setup header when database isn\'t initialized yet\n if (!data) {\n return (\n <header className="sticky top-0 z-20 border-b border-border bg-background">\n <div className="container mx-auto px-4 h-16 flex justify-between items-center">\n <div className="flex items-center gap-2">\n <span className="text-xl font-semibold">Welcome</span>\n </div>\n <Button asChild size="sm">\n <Link href="/admin">Setup Site \u2192</Link>\n </Button>\n </div>\n </header>\n )\n }\n\n return (\n <header\n className="sticky top-0 z-20 border-b border-border bg-background"\n {...(theme ? { "data-theme": theme } : {})}\n >\n <div className="container mx-auto px-4 h-16 flex justify-between items-center">\n {/* Left section: Logo + Left Nav Links */}\n <div className="flex items-center gap-8">\n <Link href="/" className="flex items-center gap-2">\n <Logo loading="eager" priority="high" variant="auto" />\n <span className="text-xl font-semibold hidden sm:inline">
|
|
1429
|
+
"marketing/payload/src/Header/Component.client.tsx": '"use client"\nimport { useHeaderTheme } from "@/providers/HeaderTheme"\nimport Link from "next/link"\nimport type React from "react"\nimport { useEffect, useMemo, useState } from "react"\n\nimport type { Header, Page, Post } from "@/payload-types"\n\nimport { Logo } from "@/components/Logo/Logo"\nimport { Button } from "@/components/ui/button"\nimport { MobileMenu } from "./MobileMenu"\nimport { HeaderNav } from "./Nav"\n\ninterface HeaderClientProps {\n data: Header | null\n}\n\n// Helper to get href from link data\nconst getLinkHref = (link: {\n type?: "custom" | "reference" | null\n reference?: {\n relationTo: "pages" | "posts"\n value: Page | Post | string | number\n } | null\n url?: string | null\n}): string => {\n if (link.type === "reference" && link.reference) {\n const { relationTo, value } = link.reference\n if (typeof value === "object" && value.slug) {\n return relationTo === "pages" ? `/${value.slug}` : `/${relationTo}/${value.slug}`\n }\n }\n return link.url || "#"\n}\n\nexport const HeaderClient: React.FC<HeaderClientProps> = ({ data }) => {\n /* Storing the value in a useState to avoid hydration errors */\n const [theme, setTheme] = useState<string | null>(null)\n const { headerTheme, setHeaderTheme } = useHeaderTheme()\n\n useEffect(() => {\n setHeaderTheme(null)\n }, [setHeaderTheme])\n\n useEffect(() => {\n if (headerTheme && headerTheme !== theme) setTheme(headerTheme)\n }, [headerTheme, theme])\n\n // Get the primary CTA (first right-positioned button item) for mobile view\n const primaryCta = useMemo(() => {\n const rightButtonItems = data?.navItems?.filter(\n (item) =>\n (item.position || "left") === "right" &&\n item.type === "link" &&\n (item.appearance || "button") === "button",\n )\n return rightButtonItems?.[0]\n }, [data?.navItems])\n\n // Show setup header when database isn\'t initialized yet\n if (!data) {\n return (\n <header className="sticky top-0 z-20 border-b border-border bg-background">\n <div className="container mx-auto px-4 h-16 flex justify-between items-center">\n <div className="flex items-center gap-2">\n <span className="text-xl font-semibold">Welcome</span>\n </div>\n <Button asChild size="sm">\n <Link href="/admin">Setup Site \u2192</Link>\n </Button>\n </div>\n </header>\n )\n }\n\n return (\n <header\n className="sticky top-0 z-20 border-b border-border bg-background"\n {...(theme ? { "data-theme": theme } : {})}\n >\n <div className="container mx-auto px-4 h-16 flex justify-between items-center">\n {/* Left section: Logo + Left Nav Links */}\n <div className="flex items-center gap-8">\n <Link href="/" className="flex items-center gap-2">\n <Logo loading="eager" priority="high" variant="auto" />\n <span className="text-xl font-semibold hidden sm:inline">SaaSify</span>\n </Link>\n {/* Left nav - hidden on tablet and below, visible on desktop */}\n <HeaderNav data={data} position="left" className="hidden lg:flex gap-6 items-center" />\n </div>\n\n {/* Right section: Right Nav Links (CTAs) */}\n <div className="flex items-center gap-3">\n {/* Full right nav - hidden on mobile and tablet, visible on desktop */}\n <HeaderNav data={data} position="right" className="hidden lg:flex gap-4 items-center" />\n\n {/* Primary CTA for tablet/mobile - visible on tablet and below, hidden on desktop */}\n {primaryCta?.link && (\n <Button asChild className="lg:hidden" size="sm">\n <Link href={getLinkHref(primaryCta.link)}>{primaryCta.label}</Link>\n </Button>\n )}\n\n {/* Mobile menu hamburger - visible on tablet and below */}\n <MobileMenu data={data} />\n </div>\n </div>\n </header>\n )\n}\n',
|
|
1426
1430
|
"marketing/payload/src/Header/Component.tsx": 'import { getCachedGlobal } from "@/utilities/getGlobals"\nimport { HeaderClient } from "./Component.client"\n\nimport type { Header as HeaderType } from "@/payload-types"\n\nexport async function Header() {\n const headerData = (await getCachedGlobal("header", 1)()) as HeaderType | null\n\n return <HeaderClient data={headerData} />\n}\n',
|
|
1427
1431
|
"marketing/payload/src/Header/MegaMenu/index.tsx": '"use client"\n\nimport { Media } from "@/components/Media"\nimport { cn } from "@/utilities/ui"\nimport {\n BarChart3,\n Building,\n ChevronDown,\n Database,\n DollarSign,\n Globe,\n Layers,\n Layout,\n type LucideIcon,\n Rocket,\n Search,\n Settings,\n Shield,\n Store,\n Target,\n Users,\n Zap,\n} from "lucide-react"\nimport Link from "next/link"\nimport type React from "react"\nimport { useState } from "react"\n\nimport type { Header, Media as MediaType, Page, Post } from "@/payload-types"\n\ntype NavItem = NonNullable<Header["navItems"]>[number]\n\nconst iconMap: Record<string, LucideIcon> = {\n layout: Layout,\n dollarSign: DollarSign,\n search: Search,\n settings: Settings,\n zap: Zap,\n layers: Layers,\n users: Users,\n building: Building,\n globe: Globe,\n store: Store,\n rocket: Rocket,\n target: Target,\n barChart: BarChart3,\n shield: Shield,\n database: Database,\n}\n\n// Helper to get href from link data\nconst getLinkHref = (link: {\n type?: "custom" | "reference" | null\n reference?: {\n relationTo: "pages" | "posts"\n value: Page | Post | string | number\n } | null\n url?: string | null\n}): string => {\n if (link.type === "reference" && link.reference) {\n const { relationTo, value } = link.reference\n if (typeof value === "object" && value.slug) {\n return relationTo === "pages" ? `/${value.slug}` : `/${relationTo}/${value.slug}`\n }\n }\n return link.url || "#"\n}\n\ninterface MegaMenuProps {\n item: NavItem\n}\n\nexport const MegaMenu: React.FC<MegaMenuProps> = ({ item }) => {\n const [isOpen, setIsOpen] = useState(false)\n\n const columns = item.megaMenuColumns || []\n const featuredItem = item.featuredItem\n\n return (\n <div\n className="relative"\n onMouseEnter={() => setIsOpen(true)}\n onMouseLeave={() => setIsOpen(false)}\n >\n {/* Trigger */}\n <button\n type="button"\n className={cn(\n "flex items-center gap-1 text-sm font-medium transition-colors hover:text-primary",\n isOpen && "text-primary",\n )}\n onClick={() => setIsOpen(!isOpen)}\n >\n {item.label}\n <ChevronDown\n className={cn("h-4 w-4 transition-transform duration-200", isOpen && "rotate-180")}\n />\n </button>\n\n {/* Dropdown */}\n {isOpen && (\n <div className="absolute left-1/2 -translate-x-1/2 top-full pt-4 z-50">\n <div\n className={cn(\n "bg-background border border-border rounded-xl shadow-xl overflow-hidden",\n "animate-in fade-in-0 zoom-in-95 duration-200",\n featuredItem?.enabled ? "min-w-[700px]" : "min-w-[500px]",\n )}\n >\n <div className="flex">\n {/* Menu Columns */}\n <div className={cn("flex-1 p-6", columns.length > 1 ? "grid grid-cols-2 gap-8" : "")}>\n {columns.map((column) => (\n <div key={column.columnLabel || "column"}>\n {column.columnLabel && (\n <div className="mb-4">\n <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">\n {column.columnLabel}\n </h3>\n {column.columnDescription && (\n <p className="text-xs text-muted-foreground mt-1">\n {column.columnDescription}\n </p>\n )}\n </div>\n )}\n <ul className="space-y-1">\n {column.items?.map((menuItem) => {\n const Icon =\n menuItem.icon && menuItem.icon !== "none" ? iconMap[menuItem.icon] : null\n const href = menuItem.link ? getLinkHref(menuItem.link) : "#"\n\n return (\n <li key={menuItem.label}>\n <Link\n href={href}\n className="flex items-start gap-3 p-2 rounded-lg hover:bg-muted transition-colors group"\n onClick={() => setIsOpen(false)}\n >\n {Icon && (\n <div className="flex-shrink-0 w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">\n <Icon className="w-4 h-4 text-primary" />\n </div>\n )}\n <div className="flex-1 min-w-0">\n <div className="text-sm font-medium group-hover:text-primary transition-colors">\n {menuItem.label}\n </div>\n {menuItem.description && (\n <div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">\n {menuItem.description}\n </div>\n )}\n </div>\n </Link>\n </li>\n )\n })}\n </ul>\n </div>\n ))}\n </div>\n\n {/* Featured Section */}\n {featuredItem?.enabled && (\n <div className="w-64 bg-muted/50 p-6 border-l border-border">\n {featuredItem.image && typeof featuredItem.image === "object" && (\n <div className="rounded-lg overflow-hidden mb-4 aspect-video">\n <Media\n resource={featuredItem.image as MediaType}\n imgClassName="w-full h-full object-cover"\n />\n </div>\n )}\n {featuredItem.heading && (\n <h4 className="font-semibold text-sm mb-2">{featuredItem.heading}</h4>\n )}\n {featuredItem.description && (\n <p className="text-xs text-muted-foreground mb-4">{featuredItem.description}</p>\n )}\n {featuredItem.link && (\n <Link\n href={getLinkHref(featuredItem.link)}\n className="text-xs font-medium text-primary hover:underline"\n onClick={() => setIsOpen(false)}\n >\n {featuredItem.link.label || "Learn more"} \u2192\n </Link>\n )}\n </div>\n )}\n </div>\n </div>\n </div>\n )}\n </div>\n )\n}\n',
|
|
1428
1432
|
"marketing/payload/src/Header/MobileMenu/HamburgerIcon.tsx": '"use client"\n\nimport { cn } from "@/utilities/ui"\nimport type React from "react"\n\ninterface HamburgerIconProps {\n isOpen: boolean\n onClick: () => void\n className?: string\n}\n\n/**\n * Animated 2-line hamburger icon that morphs into an X when open\n */\nexport const HamburgerIcon: React.FC<HamburgerIconProps> = ({ isOpen, onClick, className }) => {\n return (\n <button\n type="button"\n onClick={onClick}\n className={cn(\n "relative w-8 h-8 flex items-center justify-center focus:outline-none",\n "lg:hidden", // Only show on tablet and below\n className,\n )}\n aria-label={isOpen ? "Close menu" : "Open menu"}\n aria-expanded={isOpen}\n >\n <div className="relative w-6 h-4 flex flex-col justify-between">\n {/* Top line */}\n <span\n className={cn(\n "absolute left-0 w-full h-0.5 bg-foreground rounded-full",\n "transition-all duration-300 ease-in-out origin-center",\n isOpen ? "top-1/2 -translate-y-1/2 rotate-45" : "top-0 translate-y-0 rotate-0",\n )}\n />\n {/* Bottom line */}\n <span\n className={cn(\n "absolute left-0 w-full h-0.5 bg-foreground rounded-full",\n "transition-all duration-300 ease-in-out origin-center",\n isOpen ? "top-1/2 -translate-y-1/2 -rotate-45" : "bottom-0 translate-y-0 rotate-0",\n )}\n />\n </div>\n </button>\n )\n}\n',
|
|
1429
|
-
"marketing/payload/src/Header/MobileMenu/index.tsx": '"use client"\n\nimport { Button } from "@/components/ui/button"\nimport { cn } from "@/utilities/ui"\nimport {\n BarChart3,\n Building,\n ChevronRight,\n Database,\n DollarSign,\n Globe,\n Layers,\n Layout,\n type LucideIcon,\n Rocket,\n Search,\n Settings,\n Shield,\n Store,\n Target,\n Users,\n Zap,\n} from "lucide-react"\nimport Link from "next/link"\nimport type React from "react"\nimport { useEffect, useState } from "react"\n\nimport type { Header, Page, Post } from "@/payload-types"\nimport { HamburgerIcon } from "./HamburgerIcon"\n\nconst iconMap: Record<string, LucideIcon> = {\n layout: Layout,\n dollarSign: DollarSign,\n search: Search,\n settings: Settings,\n zap: Zap,\n layers: Layers,\n users: Users,\n building: Building,\n globe: Globe,\n store: Store,\n rocket: Rocket,\n target: Target,\n barChart: BarChart3,\n shield: Shield,\n database: Database,\n}\n\n// Helper to get href from link data\nconst getLinkHref = (link: {\n type?: "custom" | "reference" | null\n reference?: {\n relationTo: "pages" | "posts"\n value: Page | Post | string | number\n } | null\n url?: string | null\n}): string => {\n if (link.type === "reference" && link.reference) {\n const { relationTo, value } = link.reference\n if (typeof value === "object" && value.slug) {\n return relationTo === "pages" ? `/${value.slug}` : `/${relationTo}/${value.slug}`\n }\n }\n return link.url || "#"\n}\n\ninterface MobileMenuProps {\n data: Header\n}\n\nexport const MobileMenu: React.FC<MobileMenuProps> = ({ data }) => {\n const [isOpen, setIsOpen] = useState(false)\n const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set())\n\n const navItems = data?.navItems || []\n const leftItems = navItems.filter((item) => (item.position || "left") === "left")\n const rightItems = navItems.filter((item) => (item.position || "left") === "right")\n\n // Separate CTA items (typically the primary action buttons)\n const ctaItems = rightItems.filter((item) => item.type === "link")\n\n // Prevent body scroll when menu is open\n useEffect(() => {\n if (isOpen) {\n document.body.style.overflow = "hidden"\n } else {\n document.body.style.overflow = ""\n }\n return () => {\n document.body.style.overflow = ""\n }\n }, [isOpen])\n\n const toggleExpanded = (id: string) => {\n setExpandedItems((prev) => {\n const next = new Set(prev)\n if (next.has(id)) {\n next.delete(id)\n } else {\n next.add(id)\n }\n return next\n })\n }\n\n const closeMenu = () => {\n setIsOpen(false)\n setExpandedItems(new Set())\n }\n\n return (\n <>\n {/* Hamburger button - visible on tablet and below */}\n <HamburgerIcon isOpen={isOpen} onClick={() => setIsOpen(!isOpen)} />\n\n {/* Mobile menu overlay */}\n <div\n className={cn(\n "fixed inset-0 z-50 lg:hidden",\n "transition-opacity duration-300",\n isOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none",\n )}\n >\n {/* Backdrop */}\n <div\n className="absolute inset-0 bg-background/80 backdrop-blur-sm"\n onClick={closeMenu}\n onKeyDown={(e) => {\n if (e.key === "Enter" || e.key === " ") {\n e.preventDefault()\n closeMenu()\n }\n }}\n role="button"\n tabIndex={0}\n aria-label="Close menu"\n />\n\n {/* Menu panel */}\n <div\n className={cn(\n "absolute top-0 right-0 h-full w-full max-w-md bg-background border-l border-border",\n "flex flex-col",\n "transition-transform duration-300 ease-out",\n isOpen ? "translate-x-0" : "translate-x-full",\n )}\n >\n {/* Header with close button */}\n <div className="flex items-center justify-between p-4 border-b border-border">\n <Link href="/" onClick={closeMenu} className="text-xl font-semibold">\n
|
|
1433
|
+
"marketing/payload/src/Header/MobileMenu/index.tsx": '"use client"\n\nimport { Button } from "@/components/ui/button"\nimport { cn } from "@/utilities/ui"\nimport {\n BarChart3,\n Building,\n ChevronRight,\n Database,\n DollarSign,\n Globe,\n Layers,\n Layout,\n type LucideIcon,\n Rocket,\n Search,\n Settings,\n Shield,\n Store,\n Target,\n Users,\n Zap,\n} from "lucide-react"\nimport Link from "next/link"\nimport type React from "react"\nimport { useEffect, useState } from "react"\n\nimport type { Header, Page, Post } from "@/payload-types"\nimport { HamburgerIcon } from "./HamburgerIcon"\n\nconst iconMap: Record<string, LucideIcon> = {\n layout: Layout,\n dollarSign: DollarSign,\n search: Search,\n settings: Settings,\n zap: Zap,\n layers: Layers,\n users: Users,\n building: Building,\n globe: Globe,\n store: Store,\n rocket: Rocket,\n target: Target,\n barChart: BarChart3,\n shield: Shield,\n database: Database,\n}\n\n// Helper to get href from link data\nconst getLinkHref = (link: {\n type?: "custom" | "reference" | null\n reference?: {\n relationTo: "pages" | "posts"\n value: Page | Post | string | number\n } | null\n url?: string | null\n}): string => {\n if (link.type === "reference" && link.reference) {\n const { relationTo, value } = link.reference\n if (typeof value === "object" && value.slug) {\n return relationTo === "pages" ? `/${value.slug}` : `/${relationTo}/${value.slug}`\n }\n }\n return link.url || "#"\n}\n\ninterface MobileMenuProps {\n data: Header\n}\n\nexport const MobileMenu: React.FC<MobileMenuProps> = ({ data }) => {\n const [isOpen, setIsOpen] = useState(false)\n const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set())\n\n const navItems = data?.navItems || []\n const leftItems = navItems.filter((item) => (item.position || "left") === "left")\n const rightItems = navItems.filter((item) => (item.position || "left") === "right")\n\n // Separate CTA items (typically the primary action buttons)\n const ctaItems = rightItems.filter((item) => item.type === "link")\n\n // Prevent body scroll when menu is open\n useEffect(() => {\n if (isOpen) {\n document.body.style.overflow = "hidden"\n } else {\n document.body.style.overflow = ""\n }\n return () => {\n document.body.style.overflow = ""\n }\n }, [isOpen])\n\n const toggleExpanded = (id: string) => {\n setExpandedItems((prev) => {\n const next = new Set(prev)\n if (next.has(id)) {\n next.delete(id)\n } else {\n next.add(id)\n }\n return next\n })\n }\n\n const closeMenu = () => {\n setIsOpen(false)\n setExpandedItems(new Set())\n }\n\n return (\n <>\n {/* Hamburger button - visible on tablet and below */}\n <HamburgerIcon isOpen={isOpen} onClick={() => setIsOpen(!isOpen)} />\n\n {/* Mobile menu overlay */}\n <div\n className={cn(\n "fixed inset-0 z-50 lg:hidden",\n "transition-opacity duration-300",\n isOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none",\n )}\n >\n {/* Backdrop */}\n <div\n className="absolute inset-0 bg-background/80 backdrop-blur-sm"\n onClick={closeMenu}\n onKeyDown={(e) => {\n if (e.key === "Enter" || e.key === " ") {\n e.preventDefault()\n closeMenu()\n }\n }}\n role="button"\n tabIndex={0}\n aria-label="Close menu"\n />\n\n {/* Menu panel */}\n <div\n className={cn(\n "absolute top-0 right-0 h-full w-full max-w-md bg-background border-l border-border",\n "flex flex-col",\n "transition-transform duration-300 ease-out",\n isOpen ? "translate-x-0" : "translate-x-full",\n )}\n >\n {/* Header with close button */}\n <div className="flex items-center justify-between p-4 border-b border-border">\n <Link href="/" onClick={closeMenu} className="text-xl font-semibold">\n SaaSify\n </Link>\n <HamburgerIcon isOpen={isOpen} onClick={closeMenu} className="lg:block" />\n </div>\n\n {/* Nav items */}\n <div className="flex-1 overflow-y-auto p-4">\n <nav className="space-y-1">\n {leftItems.map((item, index) => {\n const itemId = item.id || `item-${index}`\n const isExpanded = expandedItems.has(itemId)\n const hasMegaMenu = item.type === "megaMenu"\n\n if (hasMegaMenu) {\n return (\n <div key={itemId}>\n {/* Expandable item */}\n <button\n type="button"\n onClick={() => toggleExpanded(itemId)}\n className={cn(\n "w-full flex items-center justify-between py-4 text-lg font-medium",\n "border-b border-border/50 transition-colors hover:text-primary",\n )}\n >\n <span>{item.label}</span>\n <ChevronRight\n className={cn(\n "w-5 h-5 transition-transform duration-200",\n isExpanded && "rotate-90",\n )}\n />\n </button>\n\n {/* Expanded content */}\n <div\n className={cn(\n "overflow-hidden transition-all duration-300",\n isExpanded ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0",\n )}\n >\n <div className="py-2 pl-4 space-y-4">\n {item.megaMenuColumns?.map((column, colIndex) => (\n <div key={column.columnLabel || `col-${colIndex}`}>\n {column.columnLabel && (\n <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">\n {column.columnLabel}\n </p>\n )}\n <ul className="space-y-1">\n {column.items?.map((menuItem) => {\n const Icon =\n menuItem.icon && menuItem.icon !== "none"\n ? iconMap[menuItem.icon]\n : null\n const href = menuItem.link ? getLinkHref(menuItem.link) : "#"\n\n return (\n <li key={menuItem.label}>\n <Link\n href={href}\n onClick={closeMenu}\n className="flex items-center gap-3 py-2 text-sm hover:text-primary transition-colors"\n >\n {Icon && <Icon className="w-4 h-4 text-muted-foreground" />}\n <span>{menuItem.label}</span>\n </Link>\n </li>\n )\n })}\n </ul>\n </div>\n ))}\n </div>\n </div>\n </div>\n )\n }\n\n // Simple link item\n const link = item.link\n if (!link) return null\n const href = getLinkHref(link)\n\n return (\n <Link\n key={itemId}\n href={href}\n onClick={closeMenu}\n className={cn(\n "block py-4 text-lg font-medium",\n "border-b border-border/50 transition-colors hover:text-primary",\n )}\n >\n {item.label}\n </Link>\n )\n })}\n </nav>\n </div>\n\n {/* Bottom CTA section */}\n <div className="p-4 border-t border-border space-y-3">\n {ctaItems.map((item, index) => {\n const link = item.link\n if (!link) return null\n const href = getLinkHref(link)\n const itemAppearance = item.appearance || "button"\n const isButton = itemAppearance === "button"\n\n if (isButton) {\n // First button item is primary (filled), others are outline\n const buttonItems = ctaItems.filter((i) => (i.appearance || "button") === "button")\n const buttonIndex = buttonItems.findIndex((i) => i.id === item.id)\n const isPrimary = buttonIndex === 0\n\n return (\n <Button\n key={item.id || `cta-${index}`}\n asChild\n variant={isPrimary ? "default" : "outline"}\n className="w-full"\n size="lg"\n >\n <Link href={href} onClick={closeMenu}>\n {item.label}\n </Link>\n </Button>\n )\n }\n\n // Link appearance - render as text link\n return (\n <Link\n key={item.id || `cta-${index}`}\n href={href}\n onClick={closeMenu}\n className="block w-full text-center py-3 text-sm font-medium hover:text-primary transition-colors"\n >\n {item.label}\n </Link>\n )\n })}\n </div>\n </div>\n </div>\n </>\n )\n}\n',
|
|
1430
1434
|
"marketing/payload/src/Header/Nav/index.tsx": `"use client"
|
|
1431
1435
|
|
|
1432
1436
|
import React from "react"
|
|
@@ -1510,15 +1514,15 @@ export const HeaderNav: React.FC<HeaderNavProps> = ({ data, position, className
|
|
|
1510
1514
|
"marketing/payload/src/access/anyone.ts": 'import type { Access } from "payload"\n\nexport const anyone: Access = () => true\n',
|
|
1511
1515
|
"marketing/payload/src/access/authenticated.ts": 'import type { AccessArgs } from "payload"\n\nimport type { User } from "@/payload-types"\n\ntype isAuthenticated = (args: AccessArgs<User>) => boolean\n\nexport const authenticated: isAuthenticated = ({ req: { user } }) => {\n return Boolean(user)\n}\n',
|
|
1512
1516
|
"marketing/payload/src/access/authenticatedOrPublished.ts": 'import type { Access } from "payload"\n\nexport const authenticatedOrPublished: Access = ({ req: { user } }) => {\n if (user) {\n return true\n }\n\n return {\n _status: {\n equals: "published",\n },\n }\n}\n',
|
|
1513
|
-
"marketing/payload/src/app/(docs)/docs/[[...slug]]/page.tsx": 'import { getDocBySlug, getDocsFromConvex } from "@/lib/docs-source"\nimport { compileMDX } from "@/lib/mdx"\nimport { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/page"\nimport type { Metadata } from "next"\nimport Link from "next/link"\nimport { notFound, redirect } from "next/navigation"\n\n// Make this route dynamic so new docs appear immediately without rebuild\n// This ensures docs added via admin portal show up right away\nexport const dynamic = "force-dynamic"\n\ninterface DocsPageProps {\n params: Promise<{\n slug?: string[]\n }>\n}\n\nexport default async function Page({ params }: DocsPageProps) {\n const { slug } = await params\n const slugPath = slug?.join("/") || ""\n\n // If this is the index page (no slug), check for an "index" doc first\n if (!slugPath) {\n // Try to find a doc with slug "index" to use as the home page\n const indexDoc = await getDocBySlug("index")\n\n if (indexDoc) {\n // Found an index doc, render it as the home page\n const { content, toc } = await compileMDX(indexDoc.content)\n return (\n <DocsPage toc={toc}>\n <DocsTitle>{indexDoc.title}</DocsTitle>\n {indexDoc.description && <DocsDescription>{indexDoc.description}</DocsDescription>}\n <DocsBody>{content}</DocsBody>\n </DocsPage>\n )\n }\n\n // No index doc found, check if there are other docs\n const docs = await getDocsFromConvex()\n\n // If there are docs, redirect to the first one\n if (docs.length > 0 && docs[0]) {\n redirect(`/docs/${docs[0].slug}`)\n }\n\n // Otherwise show an empty state\n return (\n <DocsPage>\n <DocsTitle>Documentation</DocsTitle>\n <DocsDescription>Welcome to
|
|
1514
|
-
"marketing/payload/src/app/(docs)/docs/layout.tsx": 'import { buildPageTree, getDocsFromConvex } from "@/lib/docs-source"\nimport { DocsLayout } from "fumadocs-ui/layouts/docs"\nimport { RootProvider } from "fumadocs-ui/provider"\nimport type { ReactNode } from "react"\n\n// Make layout dynamic so navigation updates when new docs are added\nexport const dynamic = "force-dynamic"\n\nexport default async function DocsLayoutWrapper({\n children,\n}: {\n children: ReactNode\n}) {\n const docs = await getDocsFromConvex()\n const pageTree = buildPageTree(docs)\n\n return (\n <RootProvider\n theme={{\n enabled: true,\n defaultTheme: "light",\n attribute: "data-theme",\n }}\n >\n <DocsLayout\n tree={pageTree}\n nav={{\n title: "
|
|
1515
|
-
"marketing/payload/src/app/(docs)/layout.tsx": 'import { getServerSideURL } from "@/utilities/getURL"\nimport { cn } from "@/utilities/ui"\nimport { GeistMono } from "geist/font/mono"\nimport type { Metadata } from "next"\nimport { Inter } from "next/font/google"\nimport type { ReactNode } from "react"\n\n// Import both - globals.css for Tailwind base + fumadocs styles\nimport "../(frontend)/globals.css"\nimport "fumadocs-ui/style.css"\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-inter",\n display: "swap",\n})\n\nexport default function DocsRootLayout({ children }: { children: ReactNode }) {\n return (\n <html\n className={cn(inter.variable, GeistMono.variable)}\n lang="en"\n suppressHydrationWarning\n data-theme="light"\n >\n <head>\n <link href="/favicon.ico" rel="icon" sizes="32x32" />\n <link href="/favicon.svg" rel="icon" type="image/svg+xml" />\n </head>\n <body className="font-sans antialiased" suppressHydrationWarning>\n {children}\n </body>\n </html>\n )\n}\n\nexport const metadata: Metadata = {\n metadataBase: new URL(getServerSideURL()),\n title: {\n default: "Documentation |
|
|
1517
|
+
"marketing/payload/src/app/(docs)/docs/[[...slug]]/page.tsx": 'import { getDocBySlug, getDocsFromConvex } from "@/lib/docs-source"\nimport { compileMDX } from "@/lib/mdx"\nimport { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/page"\nimport type { Metadata } from "next"\nimport Link from "next/link"\nimport { notFound, redirect } from "next/navigation"\n\n// Make this route dynamic so new docs appear immediately without rebuild\n// This ensures docs added via admin portal show up right away\nexport const dynamic = "force-dynamic"\n\ninterface DocsPageProps {\n params: Promise<{\n slug?: string[]\n }>\n}\n\nexport default async function Page({ params }: DocsPageProps) {\n const { slug } = await params\n const slugPath = slug?.join("/") || ""\n\n // If this is the index page (no slug), check for an "index" doc first\n if (!slugPath) {\n // Try to find a doc with slug "index" to use as the home page\n const indexDoc = await getDocBySlug("index")\n\n if (indexDoc) {\n // Found an index doc, render it as the home page\n const { content, toc } = await compileMDX(indexDoc.content)\n return (\n <DocsPage toc={toc}>\n <DocsTitle>{indexDoc.title}</DocsTitle>\n {indexDoc.description && <DocsDescription>{indexDoc.description}</DocsDescription>}\n <DocsBody>{content}</DocsBody>\n </DocsPage>\n )\n }\n\n // No index doc found, check if there are other docs\n const docs = await getDocsFromConvex()\n\n // If there are docs, redirect to the first one\n if (docs.length > 0 && docs[0]) {\n redirect(`/docs/${docs[0].slug}`)\n }\n\n // Otherwise show an empty state\n return (\n <DocsPage>\n <DocsTitle>Documentation</DocsTitle>\n <DocsDescription>Welcome to SaaSify documentation</DocsDescription>\n <DocsBody>\n <div className="flex flex-col items-center justify-center py-12 text-center">\n <h2 className="text-xl font-semibold mb-4">No documentation yet</h2>\n <p className="text-muted-foreground mb-6">\n Documentation is coming soon. Check back later!\n </p>\n <Link\n href="/"\n className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"\n >\n Back to Home\n </Link>\n </div>\n </DocsBody>\n </DocsPage>\n )\n }\n\n // Try to find the doc\n const doc = await getDocBySlug(slugPath)\n\n if (!doc) {\n notFound()\n }\n\n // Compile MDX content\n const { content, toc } = await compileMDX(doc.content)\n\n return (\n <DocsPage toc={toc}>\n <DocsTitle>{doc.title}</DocsTitle>\n {doc.description && <DocsDescription>{doc.description}</DocsDescription>}\n <DocsBody>{content}</DocsBody>\n </DocsPage>\n )\n}\n\n// Removed generateStaticParams to make route fully dynamic\n// This allows new docs to appear immediately without requiring a rebuild\n// export async function generateStaticParams() {\n// const slugs = await getAllDocSlugs()\n// return [{ slug: [] }, ...slugs.map((slug) => ({ slug }))]\n// }\n\nexport async function generateMetadata({ params }: DocsPageProps): Promise<Metadata> {\n const { slug } = await params\n const slugPath = slug?.join("/") || "index"\n\n const doc = await getDocBySlug(slugPath)\n\n if (!doc) {\n return {\n title: "Documentation | SaaSify",\n }\n }\n\n return {\n title: `${doc.title} | SaaSify Docs`,\n description: doc.description || `Learn about ${doc.title} in SaaSify documentation.`,\n openGraph: {\n title: `${doc.title} | SaaSify Docs`,\n description: doc.description || `Learn about ${doc.title} in SaaSify documentation.`,\n type: "article",\n },\n }\n}\n',
|
|
1518
|
+
"marketing/payload/src/app/(docs)/docs/layout.tsx": 'import { buildPageTree, getDocsFromConvex } from "@/lib/docs-source"\nimport { DocsLayout } from "fumadocs-ui/layouts/docs"\nimport { RootProvider } from "fumadocs-ui/provider"\nimport type { ReactNode } from "react"\n\n// Make layout dynamic so navigation updates when new docs are added\nexport const dynamic = "force-dynamic"\n\nexport default async function DocsLayoutWrapper({\n children,\n}: {\n children: ReactNode\n}) {\n const docs = await getDocsFromConvex()\n const pageTree = buildPageTree(docs)\n\n return (\n <RootProvider\n theme={{\n enabled: true,\n defaultTheme: "light",\n attribute: "data-theme",\n }}\n >\n <DocsLayout\n tree={pageTree}\n nav={{\n title: "SaaSify Docs",\n url: "/docs",\n }}\n sidebar={{\n defaultOpenLevel: 1,\n }}\n >\n {children}\n </DocsLayout>\n </RootProvider>\n )\n}\n',
|
|
1519
|
+
"marketing/payload/src/app/(docs)/layout.tsx": 'import { getServerSideURL } from "@/utilities/getURL"\nimport { cn } from "@/utilities/ui"\nimport { GeistMono } from "geist/font/mono"\nimport type { Metadata } from "next"\nimport { Inter } from "next/font/google"\nimport type { ReactNode } from "react"\n\n// Import both - globals.css for Tailwind base + fumadocs styles\nimport "../(frontend)/globals.css"\nimport "fumadocs-ui/style.css"\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-inter",\n display: "swap",\n})\n\nexport default function DocsRootLayout({ children }: { children: ReactNode }) {\n return (\n <html\n className={cn(inter.variable, GeistMono.variable)}\n lang="en"\n suppressHydrationWarning\n data-theme="light"\n >\n <head>\n <link href="/favicon.ico" rel="icon" sizes="32x32" />\n <link href="/favicon.svg" rel="icon" type="image/svg+xml" />\n </head>\n <body className="font-sans antialiased" suppressHydrationWarning>\n {children}\n </body>\n </html>\n )\n}\n\nexport const metadata: Metadata = {\n metadataBase: new URL(getServerSideURL()),\n title: {\n default: "Documentation | SaaSify",\n template: "%s | SaaSify Docs",\n },\n description: "SaaSify documentation - learn how to use the platform and boost your team productivity.",\n}\n',
|
|
1516
1520
|
"marketing/payload/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts": 'import config from "@payload-config"\nimport { getServerSideSitemap } from "next-sitemap"\nimport { unstable_cache } from "next/cache"\nimport { getPayload } from "payload"\n\nconst getPagesSitemap = unstable_cache(\n async () => {\n const payload = await getPayload({ config })\n const SITE_URL =\n process.env.NEXT_PUBLIC_SERVER_URL ||\n process.env.VERCEL_PROJECT_PRODUCTION_URL ||\n "https://example.com"\n\n const results = await payload.find({\n collection: "pages",\n overrideAccess: false,\n draft: false,\n depth: 0,\n limit: 1000,\n pagination: false,\n where: {\n _status: {\n equals: "published",\n },\n },\n select: {\n slug: true,\n updatedAt: true,\n },\n })\n\n const dateFallback = new Date().toISOString()\n\n const defaultSitemap = [\n {\n loc: `${SITE_URL}/search`,\n lastmod: dateFallback,\n },\n {\n loc: `${SITE_URL}/posts`,\n lastmod: dateFallback,\n },\n ]\n\n const sitemap = results.docs\n ? results.docs\n .filter((page) => Boolean(page?.slug))\n .map((page) => {\n return {\n loc: page?.slug === "home" ? `${SITE_URL}/` : `${SITE_URL}/${page?.slug}`,\n lastmod: page.updatedAt || dateFallback,\n }\n })\n : []\n\n return [...defaultSitemap, ...sitemap]\n },\n ["pages-sitemap"],\n {\n tags: ["pages-sitemap"],\n },\n)\n\nexport async function GET() {\n const sitemap = await getPagesSitemap()\n\n return getServerSideSitemap(sitemap)\n}\n',
|
|
1517
1521
|
"marketing/payload/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts": 'import config from "@payload-config"\nimport { getServerSideSitemap } from "next-sitemap"\nimport { unstable_cache } from "next/cache"\nimport { getPayload } from "payload"\n\nconst getPostsSitemap = unstable_cache(\n async () => {\n const payload = await getPayload({ config })\n const SITE_URL =\n process.env.NEXT_PUBLIC_SERVER_URL ||\n process.env.VERCEL_PROJECT_PRODUCTION_URL ||\n "https://example.com"\n\n const results = await payload.find({\n collection: "posts",\n overrideAccess: false,\n draft: false,\n depth: 0,\n limit: 1000,\n pagination: false,\n where: {\n _status: {\n equals: "published",\n },\n },\n select: {\n slug: true,\n updatedAt: true,\n },\n })\n\n const dateFallback = new Date().toISOString()\n\n const sitemap = results.docs\n ? results.docs\n .filter((post) => Boolean(post?.slug))\n .map((post) => ({\n loc: `${SITE_URL}/posts/${post?.slug}`,\n lastmod: post.updatedAt || dateFallback,\n }))\n : []\n\n return sitemap\n },\n ["posts-sitemap"],\n {\n tags: ["posts-sitemap"],\n },\n)\n\nexport async function GET() {\n const sitemap = await getPostsSitemap()\n\n return getServerSideSitemap(sitemap)\n}\n',
|
|
1518
1522
|
"marketing/payload/src/app/(frontend)/[slug]/page.client.tsx": '"use client"\nimport { useHeaderTheme } from "@/providers/HeaderTheme"\nimport React, { useEffect } from "react"\n\nconst PageClient: React.FC = () => {\n /* Force the header to be dark mode while we have an image behind it */\n const { setHeaderTheme } = useHeaderTheme()\n\n useEffect(() => {\n setHeaderTheme("light")\n }, [setHeaderTheme])\n return <React.Fragment />\n}\n\nexport default PageClient\n',
|
|
1519
1523
|
"marketing/payload/src/app/(frontend)/[slug]/page.tsx": 'import type { Metadata } from "next"\n\nimport { PayloadRedirects } from "@/components/PayloadRedirects"\nimport { homeStatic } from "@/endpoints/seed/home-static"\nimport configPromise from "@payload-config"\nimport { draftMode } from "next/headers"\nimport { type RequiredDataFromCollectionSlug, getPayload } from "payload"\nimport { cache } from "react"\n\nimport { RenderBlocks } from "@/blocks/RenderBlocks"\nimport { LivePreviewListener } from "@/components/LivePreviewListener"\nimport { RenderHero } from "@/heros/RenderHero"\nimport { generateMeta } from "@/utilities/generateMeta"\nimport PageClient from "./page.client"\n\nexport async function generateStaticParams() {\n const payload = await getPayload({ config: configPromise })\n const pages = await payload.find({\n collection: "pages",\n draft: false,\n limit: 1000,\n overrideAccess: false,\n pagination: false,\n select: {\n slug: true,\n },\n })\n\n const params = pages.docs\n ?.filter((doc) => {\n return doc.slug !== "home"\n })\n .map(({ slug }) => {\n return { slug }\n })\n\n return params\n}\n\ntype Args = {\n params: Promise<{\n slug?: string\n }>\n}\n\nexport default async function Page({ params: paramsPromise }: Args) {\n const { isEnabled: draft } = await draftMode()\n const { slug = "home" } = await paramsPromise\n // Decode to support slugs with special characters\n const decodedSlug = decodeURIComponent(slug)\n const url = `/${decodedSlug}`\n let page: RequiredDataFromCollectionSlug<"pages"> | null\n\n page = await queryPageBySlug({\n slug: decodedSlug,\n })\n\n // Remove this code once your website is seeded\n if (!page && slug === "home") {\n page = homeStatic\n }\n\n if (!page) {\n return <PayloadRedirects url={url} />\n }\n\n const { hero, layout } = page\n\n return (\n <article className="pt-16 pb-24">\n <PageClient />\n {/* Allows redirects for valid pages too */}\n <PayloadRedirects disableNotFound url={url} />\n\n {draft && <LivePreviewListener />}\n\n <RenderHero {...hero} />\n <RenderBlocks blocks={layout} />\n </article>\n )\n}\n\nexport async function generateMetadata({ params: paramsPromise }: Args): Promise<Metadata> {\n const { slug = "home" } = await paramsPromise\n // Decode to support slugs with special characters\n const decodedSlug = decodeURIComponent(slug)\n const page = await queryPageBySlug({\n slug: decodedSlug,\n })\n\n return generateMeta({ doc: page })\n}\n\nconst queryPageBySlug = cache(async ({ slug }: { slug: string }) => {\n const { isEnabled: draft } = await draftMode()\n\n const payload = await getPayload({ config: configPromise })\n\n const result = await payload.find({\n collection: "pages",\n depth: 2, // Populate relationships in rich text content\n draft,\n limit: 1,\n pagination: false,\n overrideAccess: draft,\n where: {\n slug: {\n equals: slug,\n },\n },\n })\n\n return result.docs?.[0] || null\n})\n',
|
|
1520
1524
|
"marketing/payload/src/app/(frontend)/api/docs-search/route.ts": 'import { getDocsFromConvex } from "@/lib/docs-source"\nimport { NextResponse } from "next/server"\n\n/**\n * Search API for documentation\n * Returns matching docs based on query\n */\nexport async function GET(request: Request) {\n const { searchParams } = new URL(request.url)\n const query = searchParams.get("q")?.toLowerCase() || ""\n\n if (!query || query.length < 2) {\n return NextResponse.json({ results: [] })\n }\n\n try {\n const docs = await getDocsFromConvex()\n\n // Simple search implementation\n const results = docs\n .filter((doc) => {\n const titleMatch = doc.title.toLowerCase().includes(query)\n const descriptionMatch = doc.description?.toLowerCase().includes(query)\n const contentMatch = doc.content.toLowerCase().includes(query)\n return titleMatch || descriptionMatch || contentMatch\n })\n .map((doc) => ({\n id: doc._id,\n title: doc.title,\n description: doc.description || "",\n url: `/docs/${doc.slug}`,\n // Extract a snippet from the content\n snippet: extractSnippet(doc.content, query),\n }))\n .slice(0, 10) // Limit results\n\n return NextResponse.json({ results })\n } catch (error) {\n console.error("Search error:", error)\n return NextResponse.json({ results: [], error: "Search failed" }, { status: 500 })\n }\n}\n\n/**\n * Extract a snippet around the search query\n */\nfunction extractSnippet(content: string, query: string): string {\n const lowerContent = content.toLowerCase()\n const index = lowerContent.indexOf(query)\n\n if (index === -1) {\n // Return first 150 chars if query not found in content\n return `${content.slice(0, 150).trim()}...`\n }\n\n // Get 50 chars before and 100 chars after the match\n const start = Math.max(0, index - 50)\n const end = Math.min(content.length, index + query.length + 100)\n\n let snippet = content.slice(start, end).trim()\n\n // Add ellipsis if needed\n if (start > 0) snippet = `...${snippet}`\n if (end < content.length) snippet = `${snippet}...`\n\n return snippet\n}\n',
|
|
1521
|
-
"marketing/payload/src/app/(frontend)/api/newsletter/route.ts": 'import { NextResponse } from "next/server"\n\nconst RESEND_API_URL = "https://api.resend.com"\n\n/**\n * Email validation regex\n */\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * Company address for CAN-SPAM compliance\n */\nconst COMPANY_ADDRESS = "KrumaLabs \u2022 102 West Main Street #501, New Albany, OH 43054"\n\n/**\n *
|
|
1525
|
+
"marketing/payload/src/app/(frontend)/api/newsletter/route.ts": 'import { NextResponse } from "next/server"\n\nconst RESEND_API_URL = "https://api.resend.com"\n\n/**\n * Email validation regex\n */\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * Company address for CAN-SPAM compliance\n */\nconst COMPANY_ADDRESS = "KrumaLabs \u2022 102 West Main Street #501, New Albany, OH 43054"\n\n/**\n * SaaSify logo URL\n */\nconst LOGO_URL = "/logo.png"\n\n/**\n * Generate newsletter confirmation email HTML with logo and branding\n */\nfunction renderNewsletterConfirmationEmail(unsubscribeUrl?: string): string {\n const emailStyles = {\n main: `\n font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', Arial, sans-serif;\n line-height: 1.6;\n color: #333;\n background-color: #f5f5f5;\n margin: 0;\n padding: 20px;\n `,\n container: `\n background-color: #ffffff;\n border-radius: 8px;\n margin: 0 auto;\n padding: 0;\n max-width: 600px;\n `,\n header: `\n padding: 32px 32px 24px;\n border-bottom: 1px solid #e5e5e5;\n `,\n logoText: `\n color: #0070f3;\n font-size: 24px;\n font-weight: 600;\n margin: 0;\n padding: 0;\n line-height: 1;\n `,\n heading: `\n color: #111111;\n font-size: 24px;\n font-weight: 600;\n line-height: 1.4;\n margin: 0 0 24px;\n `,\n content: `\n padding: 32px;\n `,\n paragraph: `\n font-size: 16px;\n color: #444444;\n margin: 0 0 16px;\n line-height: 1.6;\n `,\n paragraphSmall: `\n font-size: 14px;\n color: #666666;\n margin: 0 0 16px;\n line-height: 1.6;\n `,\n button: `\n background-color: #0070f3;\n color: white;\n padding: 14px 32px;\n text-decoration: none;\n border-radius: 6px;\n display: inline-block;\n font-weight: 500;\n font-size: 16px;\n `,\n footer: `\n padding: 24px 32px;\n text-align: center;\n `,\n footerText: `\n color: #666666;\n font-size: 14px;\n line-height: 1.6;\n margin: 0 0 8px;\n `,\n footerTextSmall: `\n color: #999999;\n font-size: 12px;\n line-height: 1.6;\n margin: 16px 0 0;\n `,\n footerAddress: `\n color: #999999;\n font-size: 11px;\n line-height: 1.6;\n margin: 8px 0 0;\n `,\n link: `\n color: #0070f3;\n text-decoration: underline;\n `,\n }\n\n return `\n<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="utf-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n</head>\n<body style="${emailStyles.main}">\n <div style="${emailStyles.container}">\n <!-- Header with Logo -->\n <div style="${emailStyles.header}">\n <a href="/" style="display: inline-flex; align-items: center; gap: 12px; text-decoration: none; color: inherit;">\n <img src="${LOGO_URL}" alt="SaaSify" width="32" height="32" style="display: block; width: 32px; height: 32px;">\n <span style="${emailStyles.logoText}">SaaSify</span>\n </a>\n </div>\n \n <!-- Content -->\n <div style="padding: 32px 32px 0;"><h1 style="${emailStyles.heading}">Welcome to the Newsletter!</h1></div>\n <div style="${emailStyles.content}">\n <p style="${emailStyles.paragraph}">\n Thanks for subscribing to the SaaSify newsletter! \u{1F389}\n </p>\n <p style="${emailStyles.paragraph}">\n You\'ll now receive updates about:\n </p>\n <ul style="${emailStyles.paragraph}">\n <li>New features and product updates</li>\n <li>Tips for boosting team productivity</li>\n <li>Industry insights and best practices</li>\n <li>Special announcements and offers</li>\n </ul>\n <p style="${emailStyles.paragraph}">\n We respect your inbox and only send emails when we have something valuable to share.\n </p>\n <div style="margin: 32px 0; text-align: center;">\n <a href="/" style="${emailStyles.button}">Visit SaaSify</a>\n </div>\n <p style="${emailStyles.paragraphSmall}">\n If you didn\'t subscribe to this newsletter, you can safely ignore this email or\n <a href="${unsubscribeUrl || "/unsubscribe"}" style="${emailStyles.link}">unsubscribe here</a>.\n </p>\n </div>\n \n <!-- Divider -->\n <hr style="border: none; border-top: 1px solid #e5e5e5; margin: 0 32px;">\n \n <!-- Footer -->\n <div style="${emailStyles.footer}">\n <p style="${emailStyles.footerText}">SaaSify - The modern platform for growing teams</p>\n <p style="${emailStyles.footerText}">\n <a href="/" style="${emailStyles.link}">Visit our website</a>\n <span style="color: #999999;"> \u2022 </span>\n <a href="/support" style="${emailStyles.link}">Support</a>\n ${unsubscribeUrl ? `<span style="color: #999999;"> \u2022 </span><a href="${unsubscribeUrl}" style="${emailStyles.link}">Unsubscribe</a>` : ""}\n </p>\n <p style="${emailStyles.footerTextSmall}">You\'re receiving this email because you signed up for the SaaSify newsletter.</p>\n <p style="${emailStyles.footerAddress}">${COMPANY_ADDRESS}</p>\n </div>\n </div>\n</body>\n</html>`.trim()\n}\n\nexport async function POST(request: Request) {\n try {\n const body = await request.json()\n const { email } = body\n\n if (!email) {\n return NextResponse.json({ success: false, message: "Email is required." }, { status: 400 })\n }\n\n // Normalize and validate email\n const normalizedEmail = email.toLowerCase().trim()\n if (!EMAIL_REGEX.test(normalizedEmail)) {\n return NextResponse.json(\n { success: false, message: "Please enter a valid email address." },\n { status: 400 },\n )\n }\n\n const apiKey = process.env.RESEND_API_KEY\n const audienceId = process.env.RESEND_AUDIENCE_NEWSLETTER\n // Marketing emails sender - update with your domain\n const fromEmail = "SaaSify Team <hello@notifications.saasify.com>"\n\n // In development without API key, just return success\n if (!apiKey) {\n console.warn("RESEND_API_KEY not set - newsletter signup simulated")\n return NextResponse.json({\n success: true,\n message: "Thanks for subscribing! Check your inbox for a confirmation email.",\n })\n }\n\n // Call Convex newsletter subscribe action via HTTP\n // This ensures consistent handling and uses the proper addNewsletterSubscriber function\n const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_URL\n if (!convexUrl) {\n console.warn("CONVEX_URL not set - newsletter signup will fail")\n return NextResponse.json(\n { success: false, message: "Server configuration error. Please try again later." },\n { status: 500 },\n )\n }\n\n try {\n const response = await fetch(`${convexUrl}/api/newsletter/subscribe`, {\n method: "POST",\n headers: {\n "Content-Type": "application/json",\n },\n body: JSON.stringify({\n email: normalizedEmail,\n }),\n })\n\n const result = await response.json()\n\n if (!result.success) {\n return NextResponse.json(result, { status: response.status })\n }\n\n return NextResponse.json(result)\n } catch (error) {\n console.error("Newsletter subscription error:", error)\n return NextResponse.json(\n { success: false, message: "Something went wrong. Please try again later." },\n { status: 500 },\n )\n }\n } catch (error) {\n console.error("Newsletter subscription error:", error)\n\n // Check if it\'s a duplicate subscriber error\n if (error instanceof Error && error.message.includes("already exists")) {\n return NextResponse.json({\n success: true,\n message: "You\'re already subscribed to our newsletter!",\n })\n }\n\n return NextResponse.json(\n { success: false, message: "Something went wrong. Please try again later." },\n { status: 500 },\n )\n }\n}\n',
|
|
1522
1526
|
"marketing/payload/src/app/(frontend)/api/pricing/route.ts": 'import { NextResponse } from "next/server"\nimport Stripe from "stripe"\n\n/**\n * Stripe Lookup Keys - defined in Stripe Dashboard for each price\n * These must match the lookup keys configured in your Stripe Dashboard\n */\nconst STRIPE_LOOKUP_KEYS = {\n free_monthly: "free_monthly",\n free_annual: "free_annual",\n pro_monthly: "pro_monthly",\n pro_annual: "pro_annual",\n business_monthly: "business_monthly",\n business_annual: "business_annual",\n} as const\n\n/**\n * Default product metadata when Stripe metadata is missing\n */\nconst DEFAULT_PRODUCT_INFO: Record<\n string,\n { description: string; popular: boolean; order: number }\n> = {\n free: { description: "Perfect for getting started", popular: false, order: 1 },\n pro: { description: "For growing businesses", popular: false, order: 2 },\n business: { description: "For teams and agencies", popular: true, order: 3 },\n}\n\n/**\n * Feature type from Stripe Entitlements\n */\ninterface PlanFeature {\n id: string\n lookupKey: string\n name: string\n metadata?: Record<string, string>\n}\n\n/**\n * All features type for comparison table\n */\ninterface AllFeature {\n id: string\n lookupKey: string\n name: string\n category?: string\n metadata?: Record<string, string>\n}\n\n/**\n * Plan pricing type with features from Stripe\n */\ninterface PlanPricing {\n planId: string\n stripeProductId: string\n name: string\n description: string\n features: PlanFeature[]\n monthlyPriceId?: string\n monthlyAmount: number\n annualPriceId?: string\n annualAmount: number\n annualDiscount: number\n popular: boolean\n order: number\n}\n\n/**\n * Fetch pricing and features from Stripe and return formatted data\n * GET /api/pricing\n *\n * Returns JSON with plan pricing and features for the marketing site\n */\nexport async function GET() {\n try {\n const stripeSecretKey = process.env.STRIPE_SECRET_KEY\n\n if (!stripeSecretKey) {\n return NextResponse.json(\n {\n plans: [],\n allFeatures: [],\n lastFetched: Date.now(),\n error: "Stripe not configured",\n },\n { status: 200 },\n )\n }\n\n const stripe = new Stripe(stripeSecretKey)\n\n // Fetch all features for comparison table\n const allFeatures: AllFeature[] = []\n try {\n const featuresResponse = await stripe.entitlements.features.list({ limit: 100 })\n for (const feature of featuresResponse.data) {\n allFeatures.push({\n id: feature.id,\n lookupKey: feature.lookup_key,\n name: feature.name,\n category: feature.metadata?.category,\n metadata: feature.metadata as Record<string, string> | undefined,\n })\n }\n } catch (err) {\n console.error("Error fetching Stripe features:", err)\n }\n\n // Fetch prices with product info\n const priceMap = new Map<string, { id: string; amount: number; productId: string }>()\n\n try {\n const allLookupKeys = Object.values(STRIPE_LOOKUP_KEYS)\n const prices = await stripe.prices.list({\n lookup_keys: allLookupKeys,\n active: true,\n expand: ["data.product"],\n limit: 20,\n })\n\n for (const price of prices.data) {\n if (price.lookup_key && price.unit_amount !== null) {\n const productId = typeof price.product === "string" ? price.product : price.product?.id\n if (productId) {\n priceMap.set(price.lookup_key, {\n id: price.id,\n amount: price.unit_amount,\n productId,\n })\n }\n }\n }\n } catch (stripeError) {\n console.error("Error fetching Stripe prices:", stripeError)\n }\n\n // Group prices by product\n const productPrices = new Map<\n string,\n { monthly?: { id: string; amount: number }; annual?: { id: string; amount: number } }\n >()\n\n for (const [lookupKey, priceData] of priceMap.entries()) {\n const isAnnual = lookupKey.endsWith("_annual")\n const existingPrices = productPrices.get(priceData.productId) || {}\n\n if (isAnnual) {\n existingPrices.annual = { id: priceData.id, amount: priceData.amount }\n } else {\n existingPrices.monthly = { id: priceData.id, amount: priceData.amount }\n }\n\n productPrices.set(priceData.productId, existingPrices)\n }\n\n // Build plans from products\n const plans: PlanPricing[] = []\n\n for (const [productId, prices] of productPrices.entries()) {\n // Fetch product details\n let product: Stripe.Product | null = null\n try {\n const fetched = await stripe.products.retrieve(productId)\n if (!fetched.deleted) {\n product = fetched\n }\n } catch (err) {\n console.error(`Error fetching product ${productId}:`, err)\n continue\n }\n\n if (!product) continue\n\n // Get plan ID from product metadata\n const planId = product.metadata?.plan_id\n if (!planId) {\n console.warn(`Product ${productId} missing plan_id metadata, skipping`)\n continue\n }\n\n // Fetch features for this product\n const features: PlanFeature[] = []\n try {\n const productFeatures = await stripe.products.listFeatures(productId, {\n limit: 100,\n })\n for (const pf of productFeatures.data) {\n features.push({\n id: pf.entitlement_feature.id,\n lookupKey: pf.entitlement_feature.lookup_key,\n name: pf.entitlement_feature.name,\n metadata: pf.entitlement_feature.metadata as Record<string, string> | undefined,\n })\n }\n } catch (err) {\n console.error(`Error fetching features for product ${productId}:`, err)\n }\n\n // Get metadata or use defaults\n const defaultInfo = DEFAULT_PRODUCT_INFO[planId] || {\n description: "",\n popular: false,\n order: 99,\n }\n const description =\n product.metadata?.description || product.description || defaultInfo.description\n const popular = product.metadata?.popular === "true" || defaultInfo.popular\n const order = Number.parseInt(product.metadata?.order || String(defaultInfo.order), 10)\n\n const monthlyAmount = prices.monthly?.amount ?? 0\n const annualAmount = prices.annual?.amount ?? 0\n\n // Calculate annual discount\n const yearlyMonthlyEquivalent = monthlyAmount * 12\n const annualDiscount =\n yearlyMonthlyEquivalent > 0\n ? Math.round(((yearlyMonthlyEquivalent - annualAmount) / yearlyMonthlyEquivalent) * 100)\n : 0\n\n plans.push({\n planId,\n stripeProductId: productId,\n name: product.name,\n description,\n features,\n monthlyPriceId: prices.monthly?.id,\n monthlyAmount,\n annualPriceId: prices.annual?.id,\n annualAmount,\n annualDiscount: Math.max(0, annualDiscount),\n popular,\n order,\n })\n }\n\n // Sort plans by order\n plans.sort((a, b) => a.order - b.order)\n\n return NextResponse.json(\n {\n plans,\n allFeatures,\n lastFetched: Date.now(),\n },\n {\n status: 200,\n headers: {\n // Cache for 1 hour on CDN, revalidate in background\n "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=7200",\n },\n },\n )\n } catch (error) {\n console.error("Pricing API error:", error)\n\n return NextResponse.json(\n {\n plans: [],\n allFeatures: [],\n lastFetched: Date.now(),\n error: "Failed to fetch pricing",\n },\n { status: 200 },\n )\n }\n}\n',
|
|
1523
1527
|
"marketing/payload/src/app/(frontend)/globals.css": '@import "tailwindcss";\n@import "tw-animate-css";\n@plugin "@tailwindcss/typography";\n\n@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));\n\n@theme {\n /* Colors */\n --color-background: hsl(var(--background));\n --color-foreground: hsl(var(--foreground));\n\n --color-card: hsl(var(--card));\n --color-card-foreground: hsl(var(--card-foreground));\n\n --color-popover: hsl(var(--popover));\n --color-popover-foreground: hsl(var(--popover-foreground));\n\n --color-primary: hsl(var(--primary));\n --color-primary-foreground: hsl(var(--primary-foreground));\n\n --color-secondary: hsl(var(--secondary));\n --color-secondary-foreground: hsl(var(--secondary-foreground));\n\n --color-muted: hsl(var(--muted));\n --color-muted-foreground: hsl(var(--muted-foreground));\n\n --color-accent: hsl(var(--accent));\n --color-accent-foreground: hsl(var(--accent-foreground));\n\n --color-destructive: hsl(var(--destructive));\n --color-destructive-foreground: hsl(var(--destructive-foreground));\n\n --color-border: hsla(var(--border));\n --color-input: hsl(var(--input));\n --color-ring: hsl(var(--ring));\n\n --color-success: hsl(var(--success));\n --color-warning: hsl(var(--warning));\n --color-error: hsl(var(--error));\n\n /* Border radius */\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n\n /* Fonts */\n --font-sans: var(--font-inter), system-ui, sans-serif;\n --font-mono: var(--font-geist-mono), monospace;\n\n /* Container - matching web app */\n --container-sm: 40rem;\n --container-md: 48rem;\n --container-lg: 64rem;\n --container-xl: 80rem;\n --container-2xl: 96rem;\n\n /* Animations */\n --animate-accordion-down: accordion-down 0.2s ease-out;\n --animate-accordion-up: accordion-up 0.2s ease-out;\n}\n\n@keyframes accordion-down {\n from {\n height: 0;\n }\n to {\n height: var(--radix-accordion-content-height);\n }\n}\n\n@keyframes accordion-up {\n from {\n height: var(--radix-accordion-content-height);\n }\n to {\n height: 0;\n }\n}\n\n:root {\n --background: 0 0% 98%; /* #f7f9fc */\n --foreground: 220 59% 15%; /* #0f1f3d */\n\n --card: 217 46% 96%; /* #e9eff8 */\n --card-foreground: 220 59% 15%; /* #0f1f3d */\n\n --popover: 0 0% 100%;\n --popover-foreground: 220 59% 15%;\n\n --primary: 220 59% 15%; /* navy */\n --primary-foreground: 0 0% 100%;\n\n --secondary: 173 55% 32%; /* darker teal for WCAG AA contrast */\n --secondary-foreground: 220 59% 15%;\n\n --muted: 217 46% 96%; /* mist */\n --muted-foreground: 220 20% 25%; /* darkened further for WCAG AA on card backgrounds */\n\n --accent: 213 35% 95%; /* soft accent */\n --accent-foreground: 220 59% 15%;\n\n --destructive: 0 68% 65%; /* #e76a6a */\n --destructive-foreground: 0 0% 100%;\n\n --border: 216 23% 89%; /* #e1e6ef */\n --input: 216 23% 89%;\n --ring: 220 59% 15%;\n\n --radius: 0.75rem;\n\n --success: 157 48% 47%; /* #3fae8c */\n --warning: 41 54% 62%; /* #d9b36a */\n --error: 0 68% 65%; /* #e76a6a */\n}\n\n[data-theme="dark"] {\n --background: 219 53% 12%; /* #0b162c */\n --foreground: 216 33% 93%; /* #f7f9fc */\n\n --card: 220 52% 11%; /* #101c32 */\n --card-foreground: 216 33% 93%;\n\n --popover: 220 52% 11%;\n --popover-foreground: 216 33% 93%;\n\n --primary: 220 59% 15%;\n --primary-foreground: 0 0% 100%;\n\n --secondary: 173 46% 44%;\n --secondary-foreground: 219 53% 12%;\n\n --muted: 221 53% 12%;\n --muted-foreground: 217 23% 75%; /* improved contrast for WCAG AA */\n\n --accent: 222 44% 16%;\n --accent-foreground: 216 33% 93%;\n\n --destructive: 0 70% 71%;\n --destructive-foreground: 219 53% 12%;\n\n --border: 222 30% 21%;\n --input: 222 30% 21%;\n --ring: 173 46% 44%;\n\n --success: 157 48% 47%;\n --warning: 41 54% 62%;\n --error: 0 70% 71%;\n}\n\n* {\n border-color: var(--color-border);\n}\n\nbody {\n background-color: var(--color-background);\n color: var(--color-foreground);\n min-height: 100vh;\n display: flex;\n flex-direction: column;\n}\n\nhtml {\n opacity: 0;\n}\n\nhtml[data-theme="dark"],\nhtml[data-theme="light"] {\n opacity: initial;\n}\n\n/* Container utility - matching web app */\n.container {\n width: 100%;\n margin-left: auto;\n margin-right: auto;\n padding-left: 1rem;\n padding-right: 1rem;\n max-width: 96rem;\n}\n\n/* Hero typography - matching web app exactly */\n.hero-content h1 {\n font-size: 2.25rem !important; /* text-4xl */\n line-height: 2.5rem !important; /* text-4xl line-height */\n font-weight: 700 !important; /* font-bold */\n letter-spacing: -0.025em !important; /* tracking-tight */\n margin-bottom: 1.5rem !important; /* mb-6 */\n color: hsl(var(--foreground)) !important;\n}\n\n.hero-content p {\n font-size: 1.25rem !important; /* text-xl */\n line-height: 1.75rem !important; /* text-xl line-height */\n color: hsl(var(--muted-foreground)) !important;\n max-width: 42rem; /* max-w-2xl */\n margin-left: auto;\n margin-right: auto;\n margin-bottom: 2rem !important; /* mb-8 */\n}\n\n.hero-content--dark h1 {\n color: white !important;\n}\n\n.hero-content--dark p {\n color: rgba(255, 255, 255, 0.8) !important;\n}\n\n@media (min-width: 768px) {\n .hero-content h1 {\n font-size: 3.75rem !important; /* md:text-6xl */\n line-height: 1 !important; /* text-6xl line-height */\n }\n}\n\n@media (min-width: 640px) {\n .container {\n padding-left: 1.5rem;\n padding-right: 1.5rem;\n }\n}\n\n@media (min-width: 1024px) {\n .container {\n padding-left: 2rem;\n padding-right: 2rem;\n }\n}\n\n/* ========================================\n PRODUCT SHOWCASE HERO STYLES\n ======================================== */\n\n/* Left-aligned hero content */\n.hero-content--left h1 {\n text-align: left !important;\n margin-left: 0 !important;\n margin-right: 0 !important;\n}\n\n.hero-content--left p {\n text-align: left !important;\n margin-left: 0 !important;\n margin-right: 0 !important;\n}\n\n/* Hero showcase section */\n.hero-showcase {\n position: relative;\n border-radius: 12px;\n overflow: hidden;\n padding: 3rem;\n min-height: 500px;\n}\n\n@media (min-width: 768px) {\n .hero-showcase {\n min-height: 600px;\n }\n}\n\n@media (min-width: 1024px) {\n .hero-showcase {\n min-height: 700px;\n }\n}\n\n/* Hero background image */\n.hero-bg-image {\n position: absolute;\n inset: 0;\n z-index: 0;\n}\n\n.hero-bg-image img {\n width: 100%;\n height: 100%;\n object-fit: cover;\n object-position: center;\n}\n\n/* Mockup centered within background */\n.hero-mockup-centered {\n position: relative;\n z-index: 10;\n max-width: 800px;\n margin: 0 auto;\n width: 100%;\n}\n\n@media (min-width: 768px) {\n .hero-mockup-centered {\n max-width: 900px;\n }\n}\n\n@media (min-width: 1024px) {\n .hero-mockup-centered {\n max-width: 1000px;\n }\n}\n\n.mockup-wrapper {\n background: hsl(var(--background));\n border-radius: 12px;\n box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 12px 24px -8px rgba(0, 0, 0, 0.15), 0 0 0 1px\n rgba(0, 0, 0, 0.05);\n overflow: hidden;\n animation: mockup-entrance 0.8s ease-out;\n width: 100%;\n}\n\n.mockup-wrapper img {\n width: 100%;\n height: auto;\n display: block;\n}\n\n@keyframes mockup-entrance {\n from {\n opacity: 0;\n transform: translateY(30px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n/* Browser chrome */\n.mockup-chrome {\n display: flex;\n align-items: center;\n padding: 12px 16px;\n background: hsl(var(--muted));\n border-bottom: 1px solid hsl(var(--border));\n}\n\n.mockup-chrome-dots {\n display: flex;\n gap: 6px;\n}\n\n.mockup-chrome-dots .dot {\n width: 12px;\n height: 12px;\n border-radius: 50%;\n}\n\n.mockup-chrome-dots .dot-red {\n background: #ff5f56;\n}\n.mockup-chrome-dots .dot-yellow {\n background: #ffbd2e;\n}\n.mockup-chrome-dots .dot-green {\n background: #27ca40;\n}\n\n.mockup-chrome-title {\n flex: 1;\n text-align: center;\n font-size: 13px;\n font-weight: 500;\n color: hsl(var(--muted-foreground));\n}\n\n.mockup-chrome-actions {\n width: 60px;\n}\n\n/* App content layout */\n.mockup-content {\n display: flex;\n min-height: 400px;\n}\n\n.mockup-sidebar {\n width: 220px;\n background: hsl(var(--card));\n border-right: 1px solid hsl(var(--border));\n flex-shrink: 0;\n}\n\n.sidebar-header {\n padding: 16px;\n border-bottom: 1px solid hsl(var(--border));\n}\n\n.sidebar-logo {\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.logo-icon {\n width: 28px;\n height: 28px;\n background: hsl(var(--primary));\n color: white;\n border-radius: 6px;\n display: flex;\n align-items: center;\n justify-content: center;\n font-weight: 700;\n font-size: 14px;\n}\n\n.logo-text {\n font-weight: 600;\n font-size: 14px;\n color: hsl(var(--foreground));\n}\n\n.sidebar-nav {\n padding: 8px;\n}\n\n.sidebar-item {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 10px 12px;\n border-radius: 6px;\n font-size: 13px;\n color: hsl(var(--muted-foreground));\n transition: all 0.15s ease;\n cursor: pointer;\n}\n\n.sidebar-item:hover {\n background: hsl(var(--accent));\n color: hsl(var(--accent-foreground));\n}\n\n.sidebar-item--active {\n background: hsl(var(--primary) / 0.12);\n color: hsl(var(--primary));\n}\n\n.sidebar-icon {\n font-size: 16px;\n}\n\n.sidebar-label {\n flex: 1;\n}\n\n.sidebar-badge {\n background: hsl(var(--primary));\n color: hsl(var(--primary-foreground));\n font-size: 11px;\n padding: 2px 6px;\n border-radius: 10px;\n font-weight: 500;\n}\n\n/* Main content area */\n.mockup-main {\n flex: 1;\n display: flex;\n flex-direction: column;\n background: hsl(var(--background));\n}\n\n.main-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px 20px;\n border-bottom: 1px solid hsl(var(--border));\n}\n\n.header-title {\n display: flex;\n flex-direction: column;\n gap: 2px;\n}\n\n.header-title h2 {\n font-size: 16px;\n font-weight: 600;\n margin: 0;\n color: hsl(var(--foreground));\n}\n\n.header-breadcrumb {\n font-size: 12px;\n color: hsl(var(--muted-foreground));\n}\n\n.header-actions {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.action-btn {\n padding: 8px 16px;\n background: hsl(var(--primary));\n color: hsl(var(--primary-foreground));\n border: none;\n border-radius: 6px;\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: all 0.3s ease;\n}\n\n.action-btn--success {\n background: #27ca40;\n}\n\n.action-btn--featured {\n background: hsl(var(--primary));\n}\n\n/* Split view */\n.main-split {\n display: flex;\n flex: 1;\n}\n\n.editor-panel {\n flex: 1;\n padding: 20px;\n border-right: 1px solid hsl(var(--border));\n}\n\n.editor-section {\n margin-bottom: 20px;\n}\n\n.editor-label {\n display: block;\n font-size: 12px;\n font-weight: 500;\n color: hsl(var(--muted-foreground));\n margin-bottom: 6px;\n}\n\n.editor-input {\n background: hsl(var(--muted));\n border: 1px solid hsl(var(--border));\n border-radius: 6px;\n padding: 10px 12px;\n font-size: 14px;\n color: hsl(var(--foreground));\n display: flex;\n align-items: center;\n}\n\n.input-text {\n flex: 1;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.input-cursor {\n width: 2px;\n height: 18px;\n background: #6c4cff;\n animation: blink 1s infinite;\n margin-left: 2px;\n}\n\n@keyframes blink {\n 0%,\n 50% {\n opacity: 1;\n }\n 51%,\n 100% {\n opacity: 0;\n }\n}\n\n.editor-select {\n background: hsl(var(--muted));\n border: 1px solid hsl(var(--border));\n border-radius: 6px;\n padding: 10px 12px;\n font-size: 14px;\n color: hsl(var(--foreground));\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n\n.select-arrow {\n font-size: 10px;\n color: hsl(var(--muted-foreground));\n}\n\n.editor-textarea {\n background: hsl(var(--muted));\n border: 1px solid hsl(var(--border));\n border-radius: 6px;\n padding: 10px 12px;\n font-size: 14px;\n color: hsl(var(--foreground));\n min-height: 80px;\n}\n\n.textarea-text {\n transition: all 0.5s ease;\n}\n\n.textarea-text--complete {\n color: hsl(var(--foreground));\n}\n\n/* Preview panel */\n.preview-panel {\n width: 320px;\n padding: 20px;\n background: hsl(var(--muted) / 0.5);\n}\n\n.preview-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 16px;\n}\n\n.preview-label {\n font-size: 12px;\n font-weight: 500;\n color: hsl(var(--muted-foreground));\n}\n\n.preview-url {\n font-size: 11px;\n color: hsl(var(--muted-foreground));\n font-family: monospace;\n}\n\n.preview-card {\n background: hsl(var(--card));\n border: 1px solid hsl(var(--border));\n border-radius: 10px;\n overflow: hidden;\n position: relative;\n transition: all 0.3s ease;\n}\n\n.preview-badge {\n position: absolute;\n top: 10px;\n right: 10px;\n background: hsl(var(--primary));\n color: white;\n font-size: 11px;\n padding: 4px 8px;\n border-radius: 4px;\n font-weight: 500;\n animation: badge-pop 0.3s ease;\n}\n\n@keyframes badge-pop {\n from {\n transform: scale(0);\n opacity: 0;\n }\n to {\n transform: scale(1);\n opacity: 1;\n }\n}\n\n.preview-image {\n height: 120px;\n background: linear-gradient(135deg, hsl(var(--muted)) 0%, hsl(var(--accent)) 100%);\n}\n\n.preview-image-placeholder {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 40px;\n}\n\n.preview-content {\n padding: 16px;\n}\n\n.preview-category-tag {\n display: inline-block;\n font-size: 11px;\n color: hsl(var(--primary));\n font-weight: 500;\n margin-bottom: 8px;\n}\n\n.preview-title {\n font-size: 16px;\n font-weight: 600;\n margin: 0 0 8px;\n color: hsl(var(--foreground));\n}\n\n.preview-description {\n font-size: 13px;\n color: hsl(var(--muted-foreground));\n line-height: 1.5;\n margin: 0 0 12px;\n}\n\n.preview-meta {\n display: flex;\n gap: 8px;\n font-size: 12px;\n}\n\n.meta-rating {\n color: #ffc24a;\n}\n\n.meta-reviews {\n color: hsl(var(--muted-foreground));\n}\n\n/* State indicators */\n.mockup-indicators {\n display: flex;\n justify-content: center;\n gap: 24px;\n padding: 16px;\n border-top: 1px solid hsl(var(--border));\n}\n\n.indicator {\n display: flex;\n align-items: center;\n gap: 8px;\n background: none;\n border: none;\n cursor: pointer;\n padding: 6px 12px;\n border-radius: 20px;\n transition: all 0.2s ease;\n}\n\n.indicator:hover {\n background: hsl(var(--accent));\n}\n\n.indicator--active {\n background: hsl(var(--primary) / 0.12);\n}\n\n.indicator-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: hsl(var(--muted-foreground));\n transition: all 0.2s ease;\n}\n\n.indicator--active .indicator-dot {\n background: hsl(var(--primary));\n box-shadow: 0 0 0 3px hsl(var(--primary) / 0.2);\n}\n\n.indicator-label {\n font-size: 12px;\n color: hsl(var(--muted-foreground));\n}\n\n.indicator--active .indicator-label {\n color: hsl(var(--foreground));\n font-weight: 500;\n}\n\n/* ========================================\n LOGO BANNER STYLES\n ======================================== */\n\n.logo-scroll-container {\n overflow: hidden;\n mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);\n}\n\n.logo-scroll-track {\n display: flex;\n animation: logo-scroll 30s linear infinite;\n}\n\n.logo-scroll-track:hover {\n animation-play-state: paused;\n}\n\n@keyframes logo-scroll {\n 0% {\n transform: translateX(0);\n }\n 100% {\n transform: translateX(-50%);\n }\n}\n\n.logo-item {\n flex-shrink: 0;\n min-width: 150px;\n}\n\n/* ========================================\n RESPONSIVE ADJUSTMENTS\n ======================================== */\n\n@media (max-width: 768px) {\n .mockup-content {\n flex-direction: column;\n }\n\n .mockup-sidebar {\n width: 100%;\n border-right: none;\n border-bottom: 1px solid hsl(var(--border));\n }\n\n .sidebar-nav {\n display: flex;\n overflow-x: auto;\n padding: 8px;\n gap: 4px;\n }\n\n .sidebar-item {\n white-space: nowrap;\n }\n\n .main-split {\n flex-direction: column;\n }\n\n .editor-panel {\n border-right: none;\n border-bottom: 1px solid hsl(var(--border));\n }\n\n .preview-panel {\n width: 100%;\n }\n\n .mockup-indicators {\n flex-wrap: wrap;\n gap: 12px;\n }\n\n .indicator-label {\n display: none;\n }\n}\n\n/* ========================================\n BIRD-INSPIRED BLOCK STYLES\n ======================================== */\n\n/* Industry Tabs Animation */\n.industry-tab-content {\n animation: tab-fade-in 0.3s ease-out;\n}\n\n@keyframes tab-fade-in {\n from {\n opacity: 0;\n transform: translateY(10px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n/* Feature Showcase Entrance Animation */\n@keyframes feature-slide-in {\n from {\n opacity: 0;\n transform: translateX(-20px);\n }\n to {\n opacity: 1;\n transform: translateX(0);\n }\n}\n\n.feature-showcase-content {\n animation: feature-slide-in 0.5s ease-out;\n}\n\n/* Testimonial Card Hover Effects */\n.testimonial-card {\n transition: transform 0.3s ease, box-shadow 0.3s ease;\n}\n\n.testimonial-card:hover {\n transform: translateY(-4px);\n box-shadow: 0 20px 40px -12px rgba(0, 0, 0, 0.15);\n}\n\n/* Stat Number Animation */\n@keyframes stat-count-up {\n from {\n opacity: 0;\n transform: translateY(20px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n.stat-number {\n animation: stat-count-up 0.6s ease-out;\n}\n\n/* Trust Column Icon Animation */\n.trust-icon {\n transition: transform 0.2s ease, background-color 0.2s ease;\n}\n\n.trust-icon:hover {\n transform: scale(1.1);\n}\n\n/* Final CTA Background Animation */\n@keyframes subtle-float {\n 0%,\n 100% {\n transform: translateY(0);\n }\n 50% {\n transform: translateY(-10px);\n }\n}\n\n.cta-decorative {\n animation: subtle-float 6s ease-in-out infinite;\n}\n\n/* Feature Grid Card Entrance */\n@keyframes card-entrance {\n from {\n opacity: 0;\n transform: translateY(20px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n.feature-card {\n animation: card-entrance 0.4s ease-out;\n animation-fill-mode: both;\n}\n\n.feature-card:nth-child(1) {\n animation-delay: 0.1s;\n}\n.feature-card:nth-child(2) {\n animation-delay: 0.2s;\n}\n.feature-card:nth-child(3) {\n animation-delay: 0.3s;\n}\n.feature-card:nth-child(4) {\n animation-delay: 0.4s;\n}\n.feature-card:nth-child(5) {\n animation-delay: 0.5s;\n}\n.feature-card:nth-child(6) {\n animation-delay: 0.6s;\n}\n\n/* Smooth Section Transitions */\nsection {\n scroll-margin-top: 80px;\n}\n\n/* Button Hover Enhancement */\n.cta-button {\n position: relative;\n overflow: hidden;\n}\n\n.cta-button::after {\n content: "";\n position: absolute;\n inset: 0;\n background: linear-gradient(rgba(255, 255, 255, 0.1), transparent);\n opacity: 0;\n transition: opacity 0.3s ease;\n}\n\n.cta-button:hover::after {\n opacity: 1;\n}\n\n/* Responsive Image Container */\n.feature-image-container {\n position: relative;\n border-radius: 12px;\n overflow: hidden;\n box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);\n transition: transform 0.3s ease, box-shadow 0.3s ease;\n}\n\n.feature-image-container:hover {\n transform: translateY(-4px);\n box-shadow: 0 30px 60px -15px rgba(0, 0, 0, 0.2);\n}\n',
|
|
1524
1528
|
"marketing/payload/src/app/(frontend)/layout.tsx": `import type { Metadata } from "next"
|
|
@@ -1592,23 +1596,23 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|
|
1592
1596
|
export const metadata: Metadata = {
|
|
1593
1597
|
metadataBase: new URL(getServerSideURL()),
|
|
1594
1598
|
title: {
|
|
1595
|
-
default: "
|
|
1596
|
-
template: "%s |
|
|
1599
|
+
default: "SaaSify - The Modern Platform for Growing Teams",
|
|
1600
|
+
template: "%s | SaaSify",
|
|
1597
1601
|
},
|
|
1598
1602
|
description:
|
|
1599
|
-
"
|
|
1603
|
+
"Streamline workflows, boost productivity, and scale your business with one powerful platform.",
|
|
1600
1604
|
keywords: [
|
|
1601
|
-
"
|
|
1602
|
-
"
|
|
1603
|
-
"
|
|
1604
|
-
"
|
|
1605
|
-
"
|
|
1606
|
-
"
|
|
1607
|
-
"
|
|
1605
|
+
"SaaS platform",
|
|
1606
|
+
"team productivity",
|
|
1607
|
+
"workflow automation",
|
|
1608
|
+
"business software",
|
|
1609
|
+
"collaboration tools",
|
|
1610
|
+
"project management",
|
|
1611
|
+
"team collaboration",
|
|
1608
1612
|
],
|
|
1609
|
-
authors: [{ name: "
|
|
1610
|
-
creator: "
|
|
1611
|
-
publisher: "
|
|
1613
|
+
authors: [{ name: "SaaSify", url: getServerSideURL() }],
|
|
1614
|
+
creator: "SaaSify",
|
|
1615
|
+
publisher: "SaaSify",
|
|
1612
1616
|
robots: {
|
|
1613
1617
|
index: true,
|
|
1614
1618
|
follow: true,
|
|
@@ -1623,11 +1627,11 @@ export const metadata: Metadata = {
|
|
|
1623
1627
|
openGraph: mergeOpenGraph(),
|
|
1624
1628
|
twitter: {
|
|
1625
1629
|
card: "summary_large_image",
|
|
1626
|
-
creator: "@
|
|
1627
|
-
site: "@
|
|
1628
|
-
title: "
|
|
1630
|
+
creator: "@saasify",
|
|
1631
|
+
site: "@saasify",
|
|
1632
|
+
title: "SaaSify - The Modern Platform for Growing Teams",
|
|
1629
1633
|
description:
|
|
1630
|
-
"
|
|
1634
|
+
"Streamline workflows, boost productivity, and scale your business with one powerful platform.",
|
|
1631
1635
|
},
|
|
1632
1636
|
verification: {
|
|
1633
1637
|
// Add your verification codes here when available
|
|
@@ -1642,12 +1646,79 @@ export const metadata: Metadata = {
|
|
|
1642
1646
|
"marketing/payload/src/app/(frontend)/not-found.tsx": 'import Link from "next/link"\n\nimport { Button } from "@/components/ui/button"\n\nexport default function NotFound() {\n return (\n <div className="container py-28">\n <div className="prose max-w-none">\n <h1 style={{ marginBottom: 0 }}>404</h1>\n <p className="mb-4">This page could not be found.</p>\n </div>\n <Button asChild variant="default">\n <Link href="/">Go home</Link>\n </Button>\n </div>\n )\n}\n',
|
|
1643
1647
|
"marketing/payload/src/app/(frontend)/page.tsx": 'import PageTemplate, { generateMetadata } from "./[slug]/page"\n\nexport default PageTemplate\n\nexport { generateMetadata }\n',
|
|
1644
1648
|
"marketing/payload/src/app/(frontend)/posts/BlogPageClient.tsx": '"use client"\n\nimport { Card, type CardPostData } from "@/components/Card"\nimport { Button } from "@/components/ui/button"\nimport { Input } from "@/components/ui/input"\nimport type { Category } from "@/payload-types"\nimport { useHeaderTheme } from "@/providers/HeaderTheme"\nimport { Search, X } from "lucide-react"\nimport { useEffect, useMemo, useState } from "react"\n\ninterface BlogPageClientProps {\n initialPosts: CardPostData[]\n categories: Category[]\n totalPosts: number\n}\n\nexport const BlogPageClient: React.FC<BlogPageClientProps> = ({\n initialPosts,\n categories,\n totalPosts,\n}) => {\n const { setHeaderTheme } = useHeaderTheme()\n const [searchQuery, setSearchQuery] = useState("")\n const [selectedCategory, setSelectedCategory] = useState<string | null>(null)\n\n useEffect(() => {\n setHeaderTheme("light")\n }, [setHeaderTheme])\n\n // Filter posts based on search query and selected category\n const filteredPosts = useMemo(() => {\n return initialPosts.filter((post) => {\n // Search filter\n const matchesSearch =\n searchQuery === "" ||\n post.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||\n post.meta?.description?.toLowerCase().includes(searchQuery.toLowerCase())\n\n // Category filter\n const matchesCategory =\n !selectedCategory ||\n (Array.isArray(post.categories) &&\n post.categories.some((cat) => {\n if (typeof cat === "object" && cat !== null) {\n return String(cat.id) === selectedCategory\n }\n return String(cat) === selectedCategory\n }))\n\n return matchesSearch && matchesCategory\n })\n }, [initialPosts, searchQuery, selectedCategory])\n\n // Get featured post (first post when no filters)\n const featuredPost = searchQuery === "" && !selectedCategory ? filteredPosts[0] : null\n const regularPosts = featuredPost ? filteredPosts.slice(1) : filteredPosts\n\n const clearFilters = () => {\n setSearchQuery("")\n setSelectedCategory(null)\n }\n\n const hasActiveFilters = searchQuery !== "" || selectedCategory !== null\n\n return (\n <div className="pt-24 pb-24">\n {/* Hero Section */}\n <div className="container mb-12">\n <div className="max-w-4xl">\n <h1 className="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight mb-4">\n Blog & Resources\n </h1>\n <p className="text-lg md:text-xl text-muted-foreground mb-8">\n Learn how to build, grow, and monetize directory websites. Strategies, tutorials, and\n success stories to help you succeed.\n </p>\n\n {/* Search Bar */}\n <div className="relative max-w-xl">\n <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />\n <Input\n type="text"\n placeholder="Search articles..."\n value={searchQuery}\n onChange={(e) => setSearchQuery(e.target.value)}\n className="pl-10 pr-10 h-12 text-base"\n />\n {searchQuery && (\n <button\n type="button"\n onClick={() => setSearchQuery("")}\n className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"\n aria-label="Clear search"\n >\n <X className="h-5 w-5" aria-hidden="true" />\n </button>\n )}\n </div>\n </div>\n </div>\n\n {/* Category Filters */}\n <div className="container mb-8">\n <div className="flex flex-wrap gap-2">\n <Button\n variant={selectedCategory === null ? "default" : "outline"}\n size="sm"\n onClick={() => setSelectedCategory(null)}\n >\n All Posts\n </Button>\n {categories.map((category) => (\n <Button\n type="button"\n key={category.id}\n variant={selectedCategory === String(category.id) ? "default" : "outline"}\n size="sm"\n onClick={() => setSelectedCategory(String(category.id))}\n >\n {category.title}\n </Button>\n ))}\n </div>\n </div>\n\n {/* Results Count & Clear Filters */}\n <div className="container mb-8">\n <div className="flex items-center justify-between">\n <p className="text-sm text-muted-foreground">\n {hasActiveFilters ? (\n <>\n Showing {filteredPosts.length} of {totalPosts} articles\n </>\n ) : (\n <>{totalPosts} articles</>\n )}\n </p>\n {hasActiveFilters && (\n <Button variant="ghost" size="sm" onClick={clearFilters}>\n Clear filters\n </Button>\n )}\n </div>\n </div>\n\n {/* Featured Post */}\n {featuredPost && (\n <div className="container mb-12">\n <div className="relative">\n <span className="absolute -top-3 left-4 bg-primary text-primary-foreground text-xs font-medium px-2 py-1 rounded z-10">\n Featured\n </span>\n <Card\n doc={featuredPost}\n relationTo="posts"\n showCategories\n className="lg:grid lg:grid-cols-2 lg:gap-8"\n />\n </div>\n </div>\n )}\n\n {/* Posts Grid */}\n <div className="container">\n {regularPosts.length > 0 ? (\n <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">\n {regularPosts.map((post, index) => (\n <Card\n key={post.slug || index}\n doc={post}\n relationTo="posts"\n showCategories\n className="h-full"\n />\n ))}\n </div>\n ) : (\n <div className="text-center py-16">\n <p className="text-lg text-muted-foreground mb-4">\n No articles found matching your criteria.\n </p>\n <Button variant="outline" onClick={clearFilters}>\n Clear filters\n </Button>\n </div>\n )}\n </div>\n </div>\n )\n}\n',
|
|
1645
|
-
"marketing/payload/src/app/(frontend)/posts/[slug]/BlogPostContent.tsx":
|
|
1649
|
+
"marketing/payload/src/app/(frontend)/posts/[slug]/BlogPostContent.tsx": `"use client"
|
|
1650
|
+
|
|
1651
|
+
import type { HeadingItem } from "@/utilities/extractHeadings"
|
|
1652
|
+
import type { DefaultTypedEditorState } from "@payloadcms/richtext-lexical"
|
|
1653
|
+
|
|
1654
|
+
import RichText from "@/components/RichText"
|
|
1655
|
+
import { TableOfContents } from "@/components/TableOfContents"
|
|
1656
|
+
import { slugify } from "@/utilities/extractHeadings"
|
|
1657
|
+
import { useEffect, useRef } from "react"
|
|
1658
|
+
|
|
1659
|
+
interface BlogPostContentProps {
|
|
1660
|
+
content: DefaultTypedEditorState
|
|
1661
|
+
headings: HeadingItem[]
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
export function BlogPostContent({ content, headings }: BlogPostContentProps) {
|
|
1665
|
+
const contentRef = useRef<HTMLDivElement>(null)
|
|
1666
|
+
|
|
1667
|
+
// Add IDs to headings after mount for TOC linking
|
|
1668
|
+
useEffect(() => {
|
|
1669
|
+
if (!contentRef.current) return
|
|
1670
|
+
|
|
1671
|
+
const headingElements = contentRef.current.querySelectorAll("h1, h2, h3, h4, h5, h6")
|
|
1672
|
+
for (const heading of headingElements) {
|
|
1673
|
+
const text = heading.textContent || ""
|
|
1674
|
+
const id = slugify(text)
|
|
1675
|
+
if (!heading.id) {
|
|
1676
|
+
heading.id = id
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
}, [])
|
|
1680
|
+
|
|
1681
|
+
return (
|
|
1682
|
+
<div className="container py-12">
|
|
1683
|
+
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12">
|
|
1684
|
+
{/* Sticky TOC Sidebar - Hidden on mobile */}
|
|
1685
|
+
<aside className="hidden lg:block lg:w-64 xl:w-72 flex-shrink-0">
|
|
1686
|
+
<div className="sticky top-24">
|
|
1687
|
+
<TableOfContents
|
|
1688
|
+
headings={headings}
|
|
1689
|
+
signUpCta={{
|
|
1690
|
+
title: "Experience SaaSify",
|
|
1691
|
+
description: "Start boosting your team's productivity today",
|
|
1692
|
+
buttonText: "Start free trial",
|
|
1693
|
+
buttonLink: "/pricing",
|
|
1694
|
+
imageSrc: "/media/hero-dashboard-500x500.webp",
|
|
1695
|
+
imageAlt: "SaaSify dashboard feature",
|
|
1696
|
+
}}
|
|
1697
|
+
/>
|
|
1698
|
+
</div>
|
|
1699
|
+
</aside>
|
|
1700
|
+
|
|
1701
|
+
{/* Main Content */}
|
|
1702
|
+
<main className="flex-1 min-w-0" ref={contentRef}>
|
|
1703
|
+
{content && (
|
|
1704
|
+
<RichText
|
|
1705
|
+
className="max-w-none prose-headings:scroll-mt-24"
|
|
1706
|
+
data={content}
|
|
1707
|
+
enableGutter={false}
|
|
1708
|
+
enableProse={true}
|
|
1709
|
+
/>
|
|
1710
|
+
)}
|
|
1711
|
+
</main>
|
|
1712
|
+
</div>
|
|
1713
|
+
</div>
|
|
1714
|
+
)
|
|
1715
|
+
}
|
|
1716
|
+
`,
|
|
1646
1717
|
"marketing/payload/src/app/(frontend)/posts/[slug]/page.client.tsx": '"use client"\nimport { useHeaderTheme } from "@/providers/HeaderTheme"\nimport React, { useEffect } from "react"\n\nconst PageClient: React.FC = () => {\n /* Force the header to be dark mode while we have an image behind it */\n const { setHeaderTheme } = useHeaderTheme()\n\n useEffect(() => {\n setHeaderTheme("dark")\n }, [setHeaderTheme])\n return <React.Fragment />\n}\n\nexport default PageClient\n',
|
|
1647
1718
|
"marketing/payload/src/app/(frontend)/posts/[slug]/page.tsx": 'import type { Metadata } from "next"\n\nimport { RelatedPosts } from "@/blocks/RelatedPosts/Component"\nimport { BlogCTA } from "@/components/BlogCTA"\nimport { PayloadRedirects } from "@/components/PayloadRedirects"\nimport configPromise from "@payload-config"\nimport { draftMode } from "next/headers"\nimport { getPayload } from "payload"\nimport { cache } from "react"\n\nimport { LivePreviewListener } from "@/components/LivePreviewListener"\nimport { PostHero } from "@/heros/PostHero"\nimport { extractHeadingsFromLexical } from "@/utilities/extractHeadings"\nimport { generateMeta } from "@/utilities/generateMeta"\nimport { BlogPostContent } from "./BlogPostContent"\nimport PageClient from "./page.client"\n\nexport async function generateStaticParams() {\n const payload = await getPayload({ config: configPromise })\n const posts = await payload.find({\n collection: "posts",\n draft: false,\n limit: 1000,\n overrideAccess: false,\n pagination: false,\n select: {\n slug: true,\n },\n })\n\n const params = posts.docs.map(({ slug }) => {\n return { slug }\n })\n\n return params\n}\n\ntype Args = {\n params: Promise<{\n slug?: string\n }>\n}\n\nexport default async function Post({ params: paramsPromise }: Args) {\n const { isEnabled: draft } = await draftMode()\n const { slug = "" } = await paramsPromise\n // Decode to support slugs with special characters\n const decodedSlug = decodeURIComponent(slug)\n const url = `/posts/${decodedSlug}`\n const post = await queryPostBySlug({ slug: decodedSlug })\n\n if (!post) return <PayloadRedirects url={url} />\n\n // Extract headings for table of contents\n const headings = extractHeadingsFromLexical(post.content)\n\n return (\n <article className="pb-0">\n <PageClient />\n\n {/* Allows redirects for valid pages too */}\n <PayloadRedirects disableNotFound url={url} />\n\n {draft && <LivePreviewListener />}\n\n {/* Hero Section */}\n <PostHero post={post} />\n\n {/* Two-column layout with TOC and content */}\n <BlogPostContent content={post.content} headings={headings} />\n\n {/* Related Posts */}\n {post.relatedPosts && post.relatedPosts.length > 0 && (\n <div className="container py-12 border-t border-border">\n <h2 className="text-2xl font-bold mb-8">Related Articles</h2>\n <RelatedPosts\n className="max-w-none"\n docs={post.relatedPosts.filter((post) => typeof post === "object")}\n />\n </div>\n )}\n\n {/* Bottom CTA */}\n <BlogCTA />\n </article>\n )\n}\n\nexport async function generateMetadata({ params: paramsPromise }: Args): Promise<Metadata> {\n const { slug = "" } = await paramsPromise\n // Decode to support slugs with special characters\n const decodedSlug = decodeURIComponent(slug)\n const post = await queryPostBySlug({ slug: decodedSlug })\n\n return generateMeta({ doc: post })\n}\n\nconst queryPostBySlug = cache(async ({ slug }: { slug: string }) => {\n const { isEnabled: draft } = await draftMode()\n\n const payload = await getPayload({ config: configPromise })\n\n const result = await payload.find({\n collection: "posts",\n depth: 2, // Populate relationships in rich text content\n draft,\n limit: 1,\n overrideAccess: draft,\n pagination: false,\n where: {\n slug: {\n equals: slug,\n },\n },\n })\n\n return result.docs?.[0] || null\n})\n',
|
|
1648
1719
|
"marketing/payload/src/app/(frontend)/posts/page/[pageNumber]/page.client.tsx": '"use client"\nimport { useHeaderTheme } from "@/providers/HeaderTheme"\nimport React, { useEffect } from "react"\n\nconst PageClient: React.FC = () => {\n /* Force the header to be dark mode while we have an image behind it */\n const { setHeaderTheme } = useHeaderTheme()\n\n useEffect(() => {\n setHeaderTheme("light")\n }, [setHeaderTheme])\n return <React.Fragment />\n}\n\nexport default PageClient\n',
|
|
1649
1720
|
"marketing/payload/src/app/(frontend)/posts/page/[pageNumber]/page.tsx": 'import type { Metadata } from "next/types"\n\nimport { CollectionArchive } from "@/components/CollectionArchive"\nimport { PageRange } from "@/components/PageRange"\nimport { Pagination } from "@/components/Pagination"\nimport configPromise from "@payload-config"\nimport { notFound } from "next/navigation"\nimport { getPayload } from "payload"\nimport PageClient from "./page.client"\n\nexport const revalidate = 600\n\ntype Args = {\n params: Promise<{\n pageNumber: string\n }>\n}\n\nexport default async function Page({ params: paramsPromise }: Args) {\n const { pageNumber } = await paramsPromise\n const payload = await getPayload({ config: configPromise })\n\n const sanitizedPageNumber = Number(pageNumber)\n\n if (!Number.isInteger(sanitizedPageNumber)) notFound()\n\n const posts = await payload.find({\n collection: "posts",\n depth: 1,\n limit: 12,\n page: sanitizedPageNumber,\n overrideAccess: false,\n })\n\n return (\n <div className="pt-24 pb-24">\n <PageClient />\n <div className="container mb-16">\n <div className="prose dark:prose-invert max-w-none">\n <h1>Posts</h1>\n </div>\n </div>\n\n <div className="container mb-8">\n <PageRange\n collection="posts"\n currentPage={posts.page}\n limit={12}\n totalDocs={posts.totalDocs}\n />\n </div>\n\n <CollectionArchive posts={posts.docs} />\n\n <div className="container">\n {posts?.page && posts?.totalPages > 1 && (\n <Pagination page={posts.page} totalPages={posts.totalPages} />\n )}\n </div>\n </div>\n )\n}\n\nexport async function generateMetadata({ params: paramsPromise }: Args): Promise<Metadata> {\n const { pageNumber } = await paramsPromise\n return {\n title: `Payload Website Template Posts Page ${pageNumber || ""}`,\n }\n}\n\nexport async function generateStaticParams() {\n const payload = await getPayload({ config: configPromise })\n const { totalDocs } = await payload.count({\n collection: "posts",\n overrideAccess: false,\n })\n\n const totalPages = Math.ceil(totalDocs / 10)\n\n const pages: { pageNumber: string }[] = []\n\n for (let i = 1; i <= totalPages; i++) {\n pages.push({ pageNumber: String(i) })\n }\n\n return pages\n}\n',
|
|
1650
|
-
"marketing/payload/src/app/(frontend)/posts/page.tsx": 'import type { Metadata } from "next/types"\n\nimport configPromise from "@payload-config"\nimport { getPayload } from "payload"\nimport { BlogPageClient } from "./BlogPageClient"\n\nexport const dynamic = "force-static"\nexport const revalidate = 600\n\nexport default async function Page() {\n const payload = await getPayload({ config: configPromise })\n\n const posts = await payload.find({\n collection: "posts",\n depth: 1,\n limit: 100,\n overrideAccess: false,\n select: {\n title: true,\n slug: true,\n categories: true,\n meta: true,\n publishedAt: true,\n },\n sort: "-publishedAt",\n })\n\n const categories = await payload.find({\n collection: "categories",\n limit: 50,\n overrideAccess: false,\n })\n\n return (\n <BlogPageClient\n initialPosts={posts.docs}\n categories={categories.docs}\n totalPosts={posts.totalDocs}\n />\n )\n}\n\nexport function generateMetadata(): Metadata {\n return {\n title: "Blog \u2014
|
|
1721
|
+
"marketing/payload/src/app/(frontend)/posts/page.tsx": 'import type { Metadata } from "next/types"\n\nimport configPromise from "@payload-config"\nimport { getPayload } from "payload"\nimport { BlogPageClient } from "./BlogPageClient"\n\nexport const dynamic = "force-static"\nexport const revalidate = 600\n\nexport default async function Page() {\n const payload = await getPayload({ config: configPromise })\n\n const posts = await payload.find({\n collection: "posts",\n depth: 1,\n limit: 100,\n overrideAccess: false,\n select: {\n title: true,\n slug: true,\n categories: true,\n meta: true,\n publishedAt: true,\n },\n sort: "-publishedAt",\n })\n\n const categories = await payload.find({\n collection: "categories",\n limit: 50,\n overrideAccess: false,\n })\n\n return (\n <BlogPageClient\n initialPosts={posts.docs}\n categories={categories.docs}\n totalPosts={posts.totalDocs}\n />\n )\n}\n\nexport function generateMetadata(): Metadata {\n return {\n title: "Blog \u2014 SaaSify Resources & Guides",\n description:\n "Tips, strategies, and insights for growing teams. Learn how to boost productivity and scale your business with SaaSify.",\n }\n}\n',
|
|
1651
1722
|
"marketing/payload/src/app/(frontend)/search/page.client.tsx": '"use client"\nimport { useHeaderTheme } from "@/providers/HeaderTheme"\nimport React, { useEffect } from "react"\n\nconst PageClient: React.FC = () => {\n /* Force the header to be dark mode while we have an image behind it */\n const { setHeaderTheme } = useHeaderTheme()\n\n useEffect(() => {\n setHeaderTheme("light")\n }, [setHeaderTheme])\n return <React.Fragment />\n}\n\nexport default PageClient\n',
|
|
1652
1723
|
"marketing/payload/src/app/(frontend)/search/page.tsx": 'import type { Metadata } from "next/types"\n\nimport type { CardPostData } from "@/components/Card"\nimport { CollectionArchive } from "@/components/CollectionArchive"\nimport { Search } from "@/search/Component"\nimport configPromise from "@payload-config"\nimport { getPayload } from "payload"\nimport PageClient from "./page.client"\n\ntype Args = {\n searchParams: Promise<{\n q: string\n }>\n}\nexport default async function Page({ searchParams: searchParamsPromise }: Args) {\n const { q: query } = await searchParamsPromise\n const payload = await getPayload({ config: configPromise })\n\n const posts = await payload.find({\n collection: "search",\n depth: 1,\n limit: 12,\n select: {\n title: true,\n slug: true,\n categories: true,\n meta: true,\n },\n // pagination: false reduces overhead if you don\'t need totalDocs\n pagination: false,\n ...(query\n ? {\n where: {\n or: [\n {\n title: {\n like: query,\n },\n },\n {\n "meta.description": {\n like: query,\n },\n },\n {\n "meta.title": {\n like: query,\n },\n },\n {\n slug: {\n like: query,\n },\n },\n ],\n },\n }\n : {}),\n })\n\n return (\n <div className="pt-24 pb-24">\n <PageClient />\n <div className="container mb-16">\n <div className="prose dark:prose-invert max-w-none text-center">\n <h1 className="mb-8 lg:mb-16">Search</h1>\n\n <div className="max-w-[50rem] mx-auto">\n <Search />\n </div>\n </div>\n </div>\n\n {posts.totalDocs > 0 ? (\n <CollectionArchive posts={posts.docs as CardPostData[]} />\n ) : (\n <div className="container">No results found.</div>\n )}\n </div>\n )\n}\n\nexport function generateMetadata(): Metadata {\n return {\n title: `Payload Website Template Search`,\n }\n}\n',
|
|
1653
1724
|
"marketing/payload/src/app/(payload)/admin/[[...segments]]/not-found.tsx": '/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */\n/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */\nimport type { Metadata } from "next"\n\nimport config from "@payload-config"\nimport { NotFoundPage, generatePageMetadata } from "@payloadcms/next/views"\nimport { importMap } from "../importMap"\n\ntype Args = {\n params: Promise<{\n segments: string[]\n }>\n searchParams: Promise<{\n [key: string]: string | string[]\n }>\n}\n\nexport const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>\n generatePageMetadata({ config, params, searchParams })\n\nconst NotFound = ({ params, searchParams }: Args) =>\n NotFoundPage({ config, params, searchParams, importMap })\n\nexport default NotFound\n',
|
|
@@ -3782,10 +3853,10 @@ export const TrustColumns: Block = {
|
|
|
3782
3853
|
"marketing/payload/src/components/BlogCTA/index.tsx": '"use client"\n\nimport { CTATracker } from "@/components/Analytics"\nimport Link from "next/link"\n\ninterface BlogCTAProps {\n headline?: string\n subheading?: string\n primaryButtonText?: string\n primaryButtonLink?: string\n secondaryButtonText?: string\n secondaryButtonLink?: string\n}\n\nexport function BlogCTA({\n headline = "Ready to launch your directory?",\n subheading = "Join hundreds of founders who chose the faster path to a profitable directory business.",\n primaryButtonText = "Get started for free",\n primaryButtonLink = "/sign-up",\n secondaryButtonText = "Book a demo",\n secondaryButtonLink = "/contact",\n}: BlogCTAProps) {\n return (\n <section className="relative overflow-hidden">\n {/* Background */}\n <div\n className="absolute inset-0"\n style={{\n background: "linear-gradient(180deg, #0F1F3D 0%, #101F3C 50%, #0F1F3D 100%)",\n }}\n >\n {/* Decorative elements */}\n <div className="absolute inset-0 opacity-10">\n <div\n className="w-full h-full"\n style={{\n backgroundImage: `\n radial-gradient(circle at 20% 80%, rgba(255,255,255,0.1) 0%, transparent 50%),\n radial-gradient(circle at 80% 20%, rgba(255,255,255,0.1) 0%, transparent 50%)\n `,\n }}\n />\n </div>\n </div>\n\n {/* Content */}\n <div className="container relative z-10 py-16 md:py-20 lg:py-24">\n <div className="max-w-3xl mx-auto text-center">\n <h2 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-6 leading-tight text-white">\n {headline}\n </h2>\n\n <p className="text-lg md:text-xl mb-8 max-w-2xl mx-auto text-white/80">{subheading}</p>\n\n <div className="flex flex-wrap justify-center gap-4">\n <CTATracker location="blog_cta" variant="primary">\n <Link\n href={primaryButtonLink}\n className="inline-flex items-center justify-center px-6 py-3 text-base font-medium bg-white text-[#0F1F3D] hover:bg-white/90 rounded-lg transition-colors"\n >\n {primaryButtonText}\n </Link>\n </CTATracker>\n <CTATracker location="blog_cta" variant="secondary">\n <Link\n href={secondaryButtonLink}\n className="inline-flex items-center justify-center px-6 py-3 text-base font-medium bg-white/10 border border-white text-white hover:bg-white/20 rounded-lg transition-colors"\n >\n {secondaryButtonText}\n </Link>\n </CTATracker>\n </div>\n </div>\n </div>\n </section>\n )\n}\n',
|
|
3783
3854
|
"marketing/payload/src/components/Card/index.tsx": '"use client"\nimport { cn } from "@/utilities/ui"\nimport useClickableCard from "@/utilities/useClickableCard"\nimport Link from "next/link"\nimport type React from "react"\nimport { Fragment } from "react"\n\nimport type { Post } from "@/payload-types"\n\nimport { Media } from "@/components/Media"\n\nexport type CardPostData = Pick<Post, "slug" | "categories" | "meta" | "title">\n\nexport const Card: React.FC<{\n alignItems?: "center"\n className?: string\n doc?: CardPostData\n relationTo?: "posts"\n showCategories?: boolean\n title?: string\n}> = (props) => {\n const { card, link } = useClickableCard({})\n const { className, doc, relationTo, showCategories, title: titleFromProps } = props\n\n const { slug, categories, meta, title } = doc || {}\n const { description, image: metaImage } = meta || {}\n\n const hasCategories = categories && Array.isArray(categories) && categories.length > 0\n const titleToUse = titleFromProps || title\n const sanitizedDescription = description?.replace(/\\s/g, " ") // replace non-breaking space with white space\n const href = `/${relationTo}/${slug}`\n\n return (\n <article\n className={cn(\n "border border-border rounded-lg overflow-hidden bg-card hover:cursor-pointer",\n className,\n )}\n ref={card.ref}\n >\n <div className="relative w-full ">\n {!metaImage && <div className="">No image</div>}\n {metaImage && typeof metaImage !== "string" && <Media resource={metaImage} size="33vw" />}\n </div>\n <div className="p-4">\n {showCategories && hasCategories && (\n <div className="uppercase text-sm mb-4">\n {showCategories && hasCategories && (\n <div>\n {categories?.map((category, index) => {\n if (typeof category === "object") {\n const { title: titleFromCategory } = category\n\n const categoryTitle = titleFromCategory || "Untitled category"\n\n const isLast = index === categories.length - 1\n\n return (\n <Fragment key={index}>\n {categoryTitle}\n {!isLast && <Fragment>, </Fragment>}\n </Fragment>\n )\n }\n\n return null\n })}\n </div>\n )}\n </div>\n )}\n {titleToUse && (\n <div className="prose">\n <h3>\n <Link className="not-prose" href={href} ref={link.ref}>\n {titleToUse}\n </Link>\n </h3>\n </div>\n )}\n {description && <div className="mt-2">{description && <p>{sanitizedDescription}</p>}</div>}\n </div>\n </article>\n )\n}\n',
|
|
3784
3855
|
"marketing/payload/src/components/CollectionArchive/index.tsx": 'import { cn } from "@/utilities/ui"\nimport type React from "react"\n\nimport { Card, type CardPostData } from "@/components/Card"\n\nexport type Props = {\n posts: CardPostData[]\n}\n\nexport const CollectionArchive: React.FC<Props> = (props) => {\n const { posts } = props\n\n return (\n <div className={cn("container")}>\n <div>\n <div className="grid grid-cols-4 sm:grid-cols-8 lg:grid-cols-12 gap-y-4 gap-x-4 lg:gap-y-8 lg:gap-x-8 xl:gap-x-8">\n {posts?.map((result, index) => {\n if (typeof result === "object" && result !== null) {\n return (\n <div className="col-span-4" key={index}>\n <Card className="h-full" doc={result} relationTo="posts" showCategories />\n </div>\n )\n }\n\n return null\n })}\n </div>\n </div>\n </div>\n )\n}\n',
|
|
3785
|
-
"marketing/payload/src/components/JsonLd/index.tsx": 'import { getServerSideURL } from "@/utilities/getURL"\nimport type React from "react"\n\nconst siteUrl = getServerSideURL()\n\n/**\n * Organization Schema - Company information\n */\nexport const OrganizationSchema: React.FC = () => {\n const schema = {\n "@context": "https://schema.org",\n "@type": "Organization",\n name: "
|
|
3856
|
+
"marketing/payload/src/components/JsonLd/index.tsx": 'import { getServerSideURL } from "@/utilities/getURL"\nimport type React from "react"\n\nconst siteUrl = getServerSideURL()\n\n/**\n * Organization Schema - Company information\n */\nexport const OrganizationSchema: React.FC = () => {\n const schema = {\n "@context": "https://schema.org",\n "@type": "Organization",\n name: "SaaSify",\n description: "The modern platform for growing teams. Streamline workflows and boost productivity.",\n url: siteUrl,\n logo: `${siteUrl}/logo.svg`,\n sameAs: [\n "https://twitter.com/saasify",\n "https://linkedin.com/company/saasify",\n "https://github.com/saasify",\n ],\n contactPoint: {\n "@type": "ContactPoint",\n contactType: "customer support",\n email: "support@saasify.com",\n },\n founder: {\n "@type": "Person",\n name: "SaaSify Team",\n },\n foundingDate: "2024",\n }\n\n return (\n <script\n type="application/ld+json"\n // biome-ignore lint/security/noDangerouslySetInnerHtml: JSON-LD requires dangerouslySetInnerHTML for proper SEO\n dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}\n />\n )\n}\n\n/**\n * Software Application Schema - Product details\n */\nexport const SoftwareApplicationSchema: React.FC = () => {\n const schema = {\n "@context": "https://schema.org",\n "@type": "SoftwareApplication",\n name: "SaaSify",\n description:\n "The modern platform for growing teams. Streamline workflows, boost productivity, and scale your business.",\n applicationCategory: "BusinessApplication",\n operatingSystem: "Web",\n url: siteUrl,\n offers: {\n "@type": "AggregateOffer",\n priceCurrency: "USD",\n lowPrice: "0",\n highPrice: "99",\n offerCount: "3",\n },\n aggregateRating: {\n "@type": "AggregateRating",\n ratingValue: "4.9",\n ratingCount: "127",\n bestRating: "5",\n worstRating: "1",\n },\n featureList: [\n "Team collaboration tools",\n "Workflow automation",\n "Real-time analytics",\n "100+ integrations",\n "Enterprise security",\n "Custom dashboards",\n ],\n screenshot: `${siteUrl}/website-template-OG.webp`,\n }\n\n return (\n <script\n type="application/ld+json"\n // biome-ignore lint/security/noDangerouslySetInnerHtml: JSON-LD requires dangerouslySetInnerHTML for proper SEO\n dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}\n />\n )\n}\n\n/**\n * WebSite Schema - Site search and navigation\n */\nexport const WebSiteSchema: React.FC = () => {\n const schema = {\n "@context": "https://schema.org",\n "@type": "WebSite",\n name: "SaaSify",\n description: "The modern platform for growing teams. Streamline workflows and boost productivity.",\n url: siteUrl,\n potentialAction: {\n "@type": "SearchAction",\n target: {\n "@type": "EntryPoint",\n urlTemplate: `${siteUrl}/search?q={search_term_string}`,\n },\n "query-input": "required name=search_term_string",\n },\n publisher: {\n "@type": "Organization",\n name: "SaaSify",\n logo: {\n "@type": "ImageObject",\n url: `${siteUrl}/logo.svg`,\n },\n },\n }\n\n return (\n <script\n type="application/ld+json"\n // biome-ignore lint/security/noDangerouslySetInnerHtml: JSON-LD requires dangerouslySetInnerHTML for proper SEO\n dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}\n />\n )\n}\n\n/**\n * Combined JSON-LD component for all schemas\n */\nexport const JsonLdSchemas: React.FC = () => {\n return (\n <>\n <OrganizationSchema />\n <SoftwareApplicationSchema />\n <WebSiteSchema />\n </>\n )\n}\n',
|
|
3786
3857
|
"marketing/payload/src/components/Link/index.tsx": 'import { Button, type ButtonProps } from "@/components/ui/button"\nimport { cn } from "@/utilities/ui"\nimport Link from "next/link"\nimport type React from "react"\n\nimport type { Page, Post } from "@/payload-types"\n\ntype CMSLinkType = {\n appearance?: "inline" | ButtonProps["variant"]\n children?: React.ReactNode\n className?: string\n label?: string | null\n newTab?: boolean | null\n reference?: {\n relationTo: "pages" | "posts"\n value: Page | Post | string | number\n } | null\n size?: ButtonProps["size"] | null\n type?: "custom" | "reference" | null\n url?: string | null\n}\n\nexport const CMSLink: React.FC<CMSLinkType> = (props) => {\n const {\n type,\n appearance = "inline",\n children,\n className,\n label,\n newTab,\n reference,\n size: sizeFromProps,\n url,\n } = props\n\n const href =\n type === "reference" && typeof reference?.value === "object" && reference.value.slug\n ? `${reference?.relationTo !== "pages" ? `/${reference?.relationTo}` : ""}/${\n reference.value.slug\n }`\n : url\n\n if (!href) return null\n\n const size = appearance === "link" ? "clear" : sizeFromProps\n const newTabProps = newTab ? { rel: "noopener noreferrer", target: "_blank" } : {}\n\n /* Ensure we don\'t break any styles set by richText */\n if (appearance === "inline") {\n return (\n <Link className={cn(className)} href={href || url || ""} {...newTabProps}>\n {label && label}\n {children && children}\n </Link>\n )\n }\n\n return (\n <Button asChild className={className} size={size} variant={appearance}>\n <Link className={cn(className)} href={href || url || ""} {...newTabProps}>\n {label && label}\n {children && children}\n </Link>\n </Button>\n )\n}\n',
|
|
3787
3858
|
"marketing/payload/src/components/LivePreviewListener/index.tsx": '"use client"\nimport { getClientSideURL } from "@/utilities/getURL"\nimport { RefreshRouteOnSave as PayloadLivePreview } from "@payloadcms/live-preview-react"\nimport { useRouter } from "next/navigation"\nimport type React from "react"\n\nexport const LivePreviewListener: React.FC = () => {\n const router = useRouter()\n return <PayloadLivePreview refresh={router.refresh} serverURL={getClientSideURL()} />\n}\n',
|
|
3788
|
-
"marketing/payload/src/components/Logo/Logo.tsx": '"use client"\n\nimport { useTheme } from "@/providers/Theme"\nimport clsx from "clsx"\n\ninterface Props {\n className?: string\n loading?: "lazy" | "eager"\n priority?: "auto" | "high" | "low"\n variant?: "default" | "light" | "auto"\n}\n\nexport const Logo = (props: Props) => {\n const {\n loading: loadingFromProps,\n priority: priorityFromProps,\n className,\n variant = "auto",\n } = props\n\n const { theme } = useTheme()\n const loading = loadingFromProps || "lazy"\n const priority = priorityFromProps || "low"\n\n // Determine which logo to show\n let src = "/logo.svg"\n if (variant === "light") {\n src = "/logo-light.svg"\n } else if (variant === "auto" && theme === "dark") {\n src = "/logo-light.svg"\n }\n\n return (\n /* eslint-disable @next/next/no-img-element */\n <img\n alt="
|
|
3859
|
+
"marketing/payload/src/components/Logo/Logo.tsx": '"use client"\n\nimport { useTheme } from "@/providers/Theme"\nimport clsx from "clsx"\n\ninterface Props {\n className?: string\n loading?: "lazy" | "eager"\n priority?: "auto" | "high" | "low"\n variant?: "default" | "light" | "auto"\n}\n\nexport const Logo = (props: Props) => {\n const {\n loading: loadingFromProps,\n priority: priorityFromProps,\n className,\n variant = "auto",\n } = props\n\n const { theme } = useTheme()\n const loading = loadingFromProps || "lazy"\n const priority = priorityFromProps || "low"\n\n // Determine which logo to show\n let src = "/logo.svg"\n if (variant === "light") {\n src = "/logo-light.svg"\n } else if (variant === "auto" && theme === "dark") {\n src = "/logo-light.svg"\n }\n\n return (\n /* eslint-disable @next/next/no-img-element */\n <img\n alt="SaaSify Logo"\n width={34}\n height={34}\n loading={loading}\n fetchPriority={priority}\n decoding="async"\n className={clsx("w-[34px] h-[34px]", className)}\n src={src}\n />\n )\n}\n',
|
|
3789
3860
|
"marketing/payload/src/components/Media/ImageMedia/index.tsx": '"use client"\n\nimport type { StaticImageData } from "next/image"\n\nimport { cn } from "@/utilities/ui"\nimport NextImage from "next/image"\nimport type React from "react"\n\nimport type { Props as MediaProps } from "../types"\n\nimport { cssVariables } from "@/cssVariables"\nimport { getMediaUrl } from "@/utilities/getMediaUrl"\n\nconst { breakpoints } = cssVariables\n\n// A base64 encoded image to use as a placeholder while the image is loading\nconst placeholderBlur =\n "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAABchJREFUWEdtlwtTG0kMhHtGM7N+AAdcDsjj///EBLzenbtuadbLJaZUTlHB+tRqSesETB3IABqQG1KbUFqDlQorBSmboqeEBcC1d8zrCixXYGZcgMsFmH8B+AngHdurAmXKOE8nHOoBrU6opcGswPi5KSP9CcBaQ9kACJH/ALAA1xm4zMD8AczvQCcAQeJVAZsy7nYApTSUzwCHUKACeUJi9TsFci7AHmDtuHYqQIC9AgQYKnSwNAig4NyOOwXq/xU47gDYggarjIpsRSEA3Fqw7AGkwgW4fgALAdiC2btKgNZwbgdMbEFpqFR2UyCR8xwAhf8bUHIGk1ckMyB5C1YkeWAdAPQBAeiD6wVYPoD1HUgXwFagZAGc6oSpTmilopoD5GzISQD3odcNIFca0BUQQM5YA2DpHV0AYURBDIAL0C+ugC0C4GedSsVUmwC8/4w8TPiwU6AClJ5RWL1PgQNkrABWdKB3YF3cBwRY5lsI4ApkKpCQi+FIgFJU/TDgDuAxAAwonJuKpGD1rkCXCR1ALyrAUSSEQAhwBdYZ6DPAgSUA2c1wKIZmRcHxMzMYR9DH8NlbkAwwApSAcABwBwTAbb6owAr0AFiZPILVEyCtMmK2jCkTwFDNUNj7nJETQx744gCUmgkZVGJUHyakEZE4W91jtGFA9KsD8Z3JFYDlhGYZLWcllwJMnplcPy+csFAgAAaIDOgeuAGoB96GLZg4kmtfMjnr6ig5oSoySsoy3ya/FMivXZWxwr0KIf9nACbfqcBEgmBSAtAlIT83R+70IWpyACamIjf5E1Iqb9ECVmnoI/FvAIRk8s2J0Y5IquQDgB+5wpScw5AUTC75VTmTs+72NUzoCvQIaAXv5Q8PDAZKLD+MxLv3RFE7KlsQChgBIlKiCv5ByaZv3gJZNm8AnVMhAN+EjrtTYQMICJpu6/0aiQnhClANlz+Bw0cIWa8ev0sBrtrhAyaXEnrfGfATQJiRKih5vKeOHNXXPFrgyamAADh0Q4F2/sESojomDS9o9k0b0H83xjB8qL+JNoTjN+enjpaBpingRh4e8MSugudM030A8FeqMI6PFIgNyPehkpZWGFEAARIQdH5LcAAqIACHkAJqg4OoBccHAuz76wr4BbzFOEa8iBuAZB8AtJHLP2VgMgJw/EIBowo7HxCAH3V6dAXEE/vZ5aZIA8BP8RKhm7Cp8BnAMnAQADdgQDA520AVIpScP+enHz0Gwp25h4i2dPg5FkDXrbsdJikQwXuWgaM5gEMk1AgH4DKKFjDf3bMD+FjEeIxLlRKYnBk2BbquvSDCAQ4gwZiMAAmH4gBTyRtEsYxi7gP6QSrc//39BrDNqG8rtYTmC4BV1SfMhOhaumFCT87zy4pPhQBZEK1kQVRjJBBi7AOlePgyAPYjwlvtagx9e/dnQraAyS894TIkkAIEYMKEc8k4EqJ68lZ5jjNqcQC2QteQOf7659umwBgPybNtK4dg9WvnMyFwXYGP7uEO1lwJgAnPNeMYMVXbIIYKFioI4PGFt+BWPVfmWJdjW2lTUnLGCswECAgaUy86iwA1464ajo0QhgMBFGyBoZahANsMpMfXr1JA1SN29m5lqgXj+UPV85uRA7yv/KYUO4Tk7Hc1AZwbIRzg0AyNj2UlAMwfSLSMnl7fdAbcxHuA27YaAMvaQ4GOjwX4RTUGAG8Ge14N963g1AynqUiFqRX9noasxT4b8entNRQYyamk/3tYcHsO7R3XJRRYOn4tw4iUnwBM5gDnySGOreAwAGo8F9IDHEcq8Pz2Kg/oXCpuIL6tOPD8LsDn0ABYQoGFRowlsAEUPPDrGAGowAbgKsgDMmE8mDy/vXQ9IAwI7u4wta+gAdAdgB64Ah9SgD4IgGKhwACoAjgNgFDhtxY8f33ZTMjqdTAiHMBPrn8ZWkEfzFdX4Oc1AHg3+ADbvN8PU8WdFKg4Tt6CQy2+D4YHaMT/JP4XzbAq98cPDIUAAAAASUVORK5CYII="\n\nexport const ImageMedia: React.FC<MediaProps> = (props) => {\n const {\n alt: altFromProps,\n fill,\n pictureClassName,\n imgClassName,\n priority,\n resource,\n size: sizeFromProps,\n src: srcFromProps,\n loading: loadingFromProps,\n } = props\n\n let width: number | undefined\n let height: number | undefined\n let alt = altFromProps\n let src: StaticImageData | string = srcFromProps || ""\n\n if (!src && resource && typeof resource === "object") {\n const { alt: altFromResource, height: fullHeight, url, width: fullWidth } = resource\n\n width = fullWidth!\n height = fullHeight!\n alt = altFromResource || ""\n\n const cacheTag = resource.updatedAt\n\n src = getMediaUrl(url, cacheTag)\n }\n\n const loading = loadingFromProps || (!priority ? "lazy" : undefined)\n\n // NOTE: this is used by the browser to determine which image to download at different screen sizes\n // The sizes attribute should specify display width in CSS pixels or viewport units\n const sizes = sizeFromProps\n ? sizeFromProps\n : Object.entries(breakpoints)\n .map(([, value]) => `(max-width: ${value}px) ${value}px`)\n .concat(["100vw"])\n .join(", ")\n\n return (\n <picture className={cn(pictureClassName)}>\n <NextImage\n alt={alt || ""}\n className={cn(imgClassName)}\n fill={fill}\n height={!fill ? height : undefined}\n placeholder="blur"\n blurDataURL={placeholderBlur}\n priority={priority}\n fetchPriority={priority ? "high" : "auto"}\n quality={fill ? 65 : 75}\n loading={loading}\n sizes={sizes}\n src={src}\n width={!fill ? width : undefined}\n />\n </picture>\n )\n}\n',
|
|
3790
3861
|
"marketing/payload/src/components/Media/VideoMedia/index.tsx": '"use client"\n\nimport { cn } from "@/utilities/ui"\nimport type React from "react"\nimport { useEffect, useRef } from "react"\n\nimport type { Props as MediaProps } from "../types"\n\nimport { getMediaUrl } from "@/utilities/getMediaUrl"\n\nexport const VideoMedia: React.FC<MediaProps> = (props) => {\n const { onClick, resource, videoClassName } = props\n\n const videoRef = useRef<HTMLVideoElement>(null)\n // const [showFallback] = useState<boolean>()\n\n useEffect(() => {\n const { current: video } = videoRef\n if (video) {\n video.addEventListener("suspend", () => {\n // setShowFallback(true);\n // console.warn(\'Video was suspended, rendering fallback image.\')\n })\n }\n }, [])\n\n if (resource && typeof resource === "object") {\n const { filename } = resource\n\n return (\n <video\n autoPlay\n className={cn(videoClassName)}\n controls={false}\n loop\n muted\n onClick={onClick}\n playsInline\n ref={videoRef}\n >\n <source src={getMediaUrl(`/media/${filename}`)} />\n </video>\n )\n }\n\n return null\n}\n',
|
|
3791
3862
|
"marketing/payload/src/components/Media/index.tsx": 'import type React from "react"\nimport { Fragment } from "react"\n\nimport type { Props } from "./types"\n\nimport { ImageMedia } from "./ImageMedia"\nimport { VideoMedia } from "./VideoMedia"\n\nexport const Media: React.FC<Props> = (props) => {\n const { className, htmlElement = "div", resource } = props\n\n const isVideo = typeof resource === "object" && resource?.mimeType?.includes("video")\n const Tag = htmlElement || Fragment\n\n return (\n <Tag\n {...(htmlElement !== null\n ? {\n className,\n }\n : {})}\n >\n {isVideo ? <VideoMedia {...props} /> : <ImageMedia {...props} />}\n </Tag>\n )\n}\n',
|
|
@@ -3794,7 +3865,7 @@ export const TrustColumns: Block = {
|
|
|
3794
3865
|
"marketing/payload/src/components/Pagination/index.tsx": '"use client"\nimport {\n Pagination as PaginationComponent,\n PaginationContent,\n PaginationEllipsis,\n PaginationItem,\n PaginationLink,\n PaginationNext,\n PaginationPrevious,\n} from "@/components/ui/pagination"\nimport { cn } from "@/utilities/ui"\nimport { useRouter } from "next/navigation"\nimport type React from "react"\n\nexport const Pagination: React.FC<{\n className?: string\n page: number\n totalPages: number\n}> = (props) => {\n const router = useRouter()\n\n const { className, page, totalPages } = props\n const hasNextPage = page < totalPages\n const hasPrevPage = page > 1\n\n const hasExtraPrevPages = page - 1 > 1\n const hasExtraNextPages = page + 1 < totalPages\n\n return (\n <div className={cn("my-12", className)}>\n <PaginationComponent>\n <PaginationContent>\n <PaginationItem>\n <PaginationPrevious\n disabled={!hasPrevPage}\n onClick={() => {\n router.push(`/posts/page/${page - 1}`)\n }}\n />\n </PaginationItem>\n\n {hasExtraPrevPages && (\n <PaginationItem>\n <PaginationEllipsis />\n </PaginationItem>\n )}\n\n {hasPrevPage && (\n <PaginationItem>\n <PaginationLink\n onClick={() => {\n router.push(`/posts/page/${page - 1}`)\n }}\n >\n {page - 1}\n </PaginationLink>\n </PaginationItem>\n )}\n\n <PaginationItem>\n <PaginationLink\n isActive\n onClick={() => {\n router.push(`/posts/page/${page}`)\n }}\n >\n {page}\n </PaginationLink>\n </PaginationItem>\n\n {hasNextPage && (\n <PaginationItem>\n <PaginationLink\n onClick={() => {\n router.push(`/posts/page/${page + 1}`)\n }}\n >\n {page + 1}\n </PaginationLink>\n </PaginationItem>\n )}\n\n {hasExtraNextPages && (\n <PaginationItem>\n <PaginationEllipsis />\n </PaginationItem>\n )}\n\n <PaginationItem>\n <PaginationNext\n disabled={!hasNextPage}\n onClick={() => {\n router.push(`/posts/page/${page + 1}`)\n }}\n />\n </PaginationItem>\n </PaginationContent>\n </PaginationComponent>\n </div>\n )\n}\n',
|
|
3795
3866
|
"marketing/payload/src/components/PayloadRedirects/index.tsx": 'import type { Page, Post } from "@/payload-types"\nimport type React from "react"\n\nimport { getCachedDocument } from "@/utilities/getDocument"\nimport { getCachedRedirects } from "@/utilities/getRedirects"\nimport { notFound, redirect } from "next/navigation"\n\ninterface Props {\n disableNotFound?: boolean\n url: string\n}\n\n/* This component helps us with SSR based dynamic redirects */\nexport const PayloadRedirects: React.FC<Props> = async ({ disableNotFound, url }) => {\n const redirects = await getCachedRedirects()()\n\n const redirectItem = redirects.find((redirect) => redirect.from === url)\n\n if (redirectItem) {\n if (redirectItem.to?.url) {\n redirect(redirectItem.to.url)\n }\n\n let redirectUrl: string\n\n if (typeof redirectItem.to?.reference?.value === "string") {\n const collection = redirectItem.to?.reference?.relationTo\n const id = redirectItem.to?.reference?.value\n\n const document = (await getCachedDocument(collection, id)()) as Page | Post\n redirectUrl = `${redirectItem.to?.reference?.relationTo !== "pages" ? `/${redirectItem.to?.reference?.relationTo}` : ""}/${\n document?.slug\n }`\n } else {\n redirectUrl = `${redirectItem.to?.reference?.relationTo !== "pages" ? `/${redirectItem.to?.reference?.relationTo}` : ""}/${\n typeof redirectItem.to?.reference?.value === "object"\n ? redirectItem.to?.reference?.value?.slug\n : ""\n }`\n }\n\n if (redirectUrl) redirect(redirectUrl)\n }\n\n if (disableNotFound) return null\n\n notFound()\n}\n',
|
|
3796
3867
|
"marketing/payload/src/components/RichText/index.tsx": 'import { MediaBlock } from "@/blocks/MediaBlock/Component"\nimport type {\n DefaultNodeTypes,\n DefaultTypedEditorState,\n SerializedBlockNode,\n SerializedLinkNode,\n SerializedRelationshipNode,\n} from "@payloadcms/richtext-lexical"\nimport {\n RichText as ConvertRichText,\n type JSXConvertersFunction,\n LinkJSXConverter,\n} from "@payloadcms/richtext-lexical/react"\n\nimport { CodeBlock, type CodeBlockProps } from "@/blocks/Code/Component"\n\nimport { BannerBlock } from "@/blocks/Banner/Component"\nimport { CallToActionBlock } from "@/blocks/CallToAction/Component"\nimport type {\n BannerBlock as BannerBlockProps,\n CallToActionBlock as CTABlockProps,\n MediaBlock as MediaBlockProps,\n Page,\n Post,\n} from "@/payload-types"\nimport { cn } from "@/utilities/ui"\nimport Link from "next/link"\n\ntype NodeTypes =\n | DefaultNodeTypes\n | SerializedBlockNode<CTABlockProps | MediaBlockProps | BannerBlockProps | CodeBlockProps>\n\nconst internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => {\n const { value, relationTo } = linkNode.fields.doc!\n if (typeof value !== "object") {\n throw new Error("Expected value to be an object")\n }\n const slug = value.slug\n return relationTo === "posts" ? `/posts/${slug}` : `/${slug}`\n}\n\n/**\n * Renders relationship nodes embedded in rich text content.\n * These are created when using the RelationshipFeature in Lexical editor.\n */\nconst RelationshipComponent = ({ node }: { node: SerializedRelationshipNode }) => {\n const { relationTo, value } = node\n\n // If value is not populated (just an ID), we can\'t render it\n if (typeof value !== "object" || value === null) {\n return null\n }\n\n // Handle different collection types\n switch (relationTo) {\n case "posts": {\n const post = value as Post\n return (\n <Link\n href={`/posts/${post.slug}`}\n className="not-prose my-4 block rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent"\n >\n <span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">\n Related Post\n </span>\n <h4 className="mt-1 font-semibold text-foreground">{post.title}</h4>\n {post.meta?.description && (\n <p className="mt-1 text-sm text-muted-foreground line-clamp-2">\n {post.meta.description}\n </p>\n )}\n </Link>\n )\n }\n case "pages": {\n const page = value as Page\n return (\n <Link\n href={`/${page.slug}`}\n className="not-prose my-4 block rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent"\n >\n <span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">\n Related Page\n </span>\n <h4 className="mt-1 font-semibold text-foreground">{page.title}</h4>\n </Link>\n )\n }\n default: {\n // Fallback for other collection types - render basic info if available\n const doc = value as { title?: string; name?: string; slug?: string }\n const title = doc.title || doc.name || "Related Content"\n return (\n <div className="not-prose my-4 rounded-lg border border-border bg-card p-4">\n <span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">\n {relationTo}\n </span>\n <h4 className="mt-1 font-semibold text-foreground">{title}</h4>\n </div>\n )\n }\n }\n}\n\nconst jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({\n ...defaultConverters,\n ...LinkJSXConverter({ internalDocToHref }),\n // Add relationship node converter - this is missing from defaultConverters\n relationship: ({ node }) => <RelationshipComponent node={node} />,\n blocks: {\n banner: ({ node }) => <BannerBlock className="col-start-2 mb-4" {...node.fields} />,\n mediaBlock: ({ node }) => (\n <MediaBlock\n className="col-start-1 col-span-3"\n imgClassName="m-0"\n {...node.fields}\n captionClassName="mx-auto max-w-[48rem]"\n enableGutter={false}\n disableInnerContainer={true}\n />\n ),\n code: ({ node }) => <CodeBlock className="col-start-2" {...node.fields} />,\n cta: ({ node }) => <CallToActionBlock {...node.fields} />,\n },\n})\n\ntype Props = {\n data: DefaultTypedEditorState\n enableGutter?: boolean\n enableProse?: boolean\n} & React.HTMLAttributes<HTMLDivElement>\n\nexport default function RichText(props: Props) {\n const { className, enableProse = true, enableGutter = true, ...rest } = props\n return (\n <ConvertRichText\n converters={jsxConverters}\n className={cn(\n "payload-richtext",\n {\n container: enableGutter,\n "prose prose-lg dark:prose-invert prose-headings:font-bold prose-headings:tracking-tight prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-blockquote:border-l-primary prose-blockquote:not-italic prose-li:marker:text-muted-foreground":\n enableProse,\n },\n // max-w-none should come after prose to properly override\n !enableGutter && "max-w-none",\n className,\n )}\n {...rest}\n />\n )\n}\n',
|
|
3797
|
-
"marketing/payload/src/components/TableOfContents/index.tsx": '"use client"\n\nimport type { HeadingItem } from "@/utilities/extractHeadings"\nimport { cn } from "@/utilities/ui"\nimport Image from "next/image"\nimport Link from "next/link"\nimport { useEffect, useState } from "react"\n\ninterface TableOfContentsProps {\n headings: HeadingItem[]\n signUpCta?: {\n title?: string\n description?: string\n buttonText?: string\n buttonLink?: string\n imageSrc?: string\n imageAlt?: string\n }\n}\n\nexport function TableOfContents({ headings, signUpCta }: TableOfContentsProps) {\n const [activeId, setActiveId] = useState<string>("")\n\n useEffect(() => {\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n setActiveId(entry.target.id)\n }\n }\n },\n {\n rootMargin: "-80px 0px -80% 0px",\n threshold: 0,\n },\n )\n\n // Observe all heading elements\n for (const heading of headings) {\n const element = document.getElementById(heading.id)\n if (element) {\n observer.observe(element)\n }\n }\n\n return () => observer.disconnect()\n }, [headings])\n\n const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {\n e.preventDefault()\n const element = document.getElementById(id)\n if (element) {\n const yOffset = -100\n const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset\n window.scrollTo({ top: y, behavior: "smooth" })\n setActiveId(id)\n }\n }\n\n if (headings.length === 0) {\n return null\n }\n\n return (\n <nav className="flex flex-col gap-6">\n {/* TOC Header */}\n <div>\n <h4 className="text-sm font-semibold text-foreground mb-4">On this page</h4>\n <ul className="space-y-2">\n {headings.map((heading) => (\n <li key={heading.id} style={{ paddingLeft: `${(heading.depth - 2) * 12}px` }}>\n <a\n href={`#${heading.id}`}\n onClick={(e) => handleClick(e, heading.id)}\n className={cn(\n "block text-sm leading-relaxed transition-colors duration-200",\n "hover:text-foreground",\n activeId === heading.id ? "text-[#3DA9A3] font-medium" : "text-muted-foreground",\n )}\n >\n {heading.text}\n </a>\n </li>\n ))}\n </ul>\n </div>\n\n {/* Sign-up CTA */}\n {signUpCta && (\n <div className="mt-4 p-4 bg-[#F7F9FC] dark:bg-[#0F1F3D]/30 rounded-xl border border-[#E1E6EF] dark:border-[#0F1F3D]">\n <div className="flex flex-col gap-3">\n {/* App Feature Mockup */}\n {signUpCta.imageSrc && (\n <div className="w-full aspect-square rounded-lg overflow-hidden bg-white dark:bg-[#0F1F3D] border border-[#E1E6EF] dark:border-[#0F1F3D]">\n <Image\n src={signUpCta.imageSrc}\n alt={signUpCta.imageAlt || "
|
|
3868
|
+
"marketing/payload/src/components/TableOfContents/index.tsx": '"use client"\n\nimport type { HeadingItem } from "@/utilities/extractHeadings"\nimport { cn } from "@/utilities/ui"\nimport Image from "next/image"\nimport Link from "next/link"\nimport { useEffect, useState } from "react"\n\ninterface TableOfContentsProps {\n headings: HeadingItem[]\n signUpCta?: {\n title?: string\n description?: string\n buttonText?: string\n buttonLink?: string\n imageSrc?: string\n imageAlt?: string\n }\n}\n\nexport function TableOfContents({ headings, signUpCta }: TableOfContentsProps) {\n const [activeId, setActiveId] = useState<string>("")\n\n useEffect(() => {\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n setActiveId(entry.target.id)\n }\n }\n },\n {\n rootMargin: "-80px 0px -80% 0px",\n threshold: 0,\n },\n )\n\n // Observe all heading elements\n for (const heading of headings) {\n const element = document.getElementById(heading.id)\n if (element) {\n observer.observe(element)\n }\n }\n\n return () => observer.disconnect()\n }, [headings])\n\n const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {\n e.preventDefault()\n const element = document.getElementById(id)\n if (element) {\n const yOffset = -100\n const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset\n window.scrollTo({ top: y, behavior: "smooth" })\n setActiveId(id)\n }\n }\n\n if (headings.length === 0) {\n return null\n }\n\n return (\n <nav className="flex flex-col gap-6">\n {/* TOC Header */}\n <div>\n <h4 className="text-sm font-semibold text-foreground mb-4">On this page</h4>\n <ul className="space-y-2">\n {headings.map((heading) => (\n <li key={heading.id} style={{ paddingLeft: `${(heading.depth - 2) * 12}px` }}>\n <a\n href={`#${heading.id}`}\n onClick={(e) => handleClick(e, heading.id)}\n className={cn(\n "block text-sm leading-relaxed transition-colors duration-200",\n "hover:text-foreground",\n activeId === heading.id ? "text-[#3DA9A3] font-medium" : "text-muted-foreground",\n )}\n >\n {heading.text}\n </a>\n </li>\n ))}\n </ul>\n </div>\n\n {/* Sign-up CTA */}\n {signUpCta && (\n <div className="mt-4 p-4 bg-[#F7F9FC] dark:bg-[#0F1F3D]/30 rounded-xl border border-[#E1E6EF] dark:border-[#0F1F3D]">\n <div className="flex flex-col gap-3">\n {/* App Feature Mockup */}\n {signUpCta.imageSrc && (\n <div className="w-full aspect-square rounded-lg overflow-hidden bg-white dark:bg-[#0F1F3D] border border-[#E1E6EF] dark:border-[#0F1F3D]">\n <Image\n src={signUpCta.imageSrc}\n alt={signUpCta.imageAlt || "SaaSify app feature"}\n width={200}\n height={200}\n className="w-full h-full object-cover"\n />\n </div>\n )}\n\n {/* Text */}\n <div>\n <p className="text-sm font-semibold text-foreground">\n {signUpCta.title || "Experience SaaSify"}\n </p>\n <p className="text-xs text-muted-foreground mt-1">\n {signUpCta.description || "Start building your directory today"}\n </p>\n </div>\n\n {/* CTA Button */}\n <Link\n href={signUpCta.buttonLink || "/pricing"}\n className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-[#0F1F3D] hover:bg-[#0B162C] dark:bg-[#3DA9A3] dark:hover:bg-[#3DA9A3]/90 rounded-lg transition-colors"\n >\n {signUpCta.buttonText || "Sign up for free"}\n </Link>\n </div>\n </div>\n )}\n </nav>\n )\n}\n',
|
|
3798
3869
|
"marketing/payload/src/components/ui/accordion.tsx": '"use client"\n\nimport * as AccordionPrimitive from "@radix-ui/react-accordion"\nimport { ChevronDownIcon } from "lucide-react"\nimport type * as React from "react"\n\nimport { cn } from "@/utilities/ui"\n\nfunction Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {\n return <AccordionPrimitive.Root data-slot="accordion" {...props} />\n}\n\nfunction AccordionItem({\n className,\n ...props\n}: React.ComponentProps<typeof AccordionPrimitive.Item>) {\n return (\n <AccordionPrimitive.Item\n data-slot="accordion-item"\n className={cn("border-b last:border-b-0", className)}\n {...props}\n />\n )\n}\n\nfunction AccordionTrigger({\n className,\n children,\n ...props\n}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {\n return (\n <AccordionPrimitive.Header className="flex">\n <AccordionPrimitive.Trigger\n data-slot="accordion-trigger"\n className={cn(\n "focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",\n className,\n )}\n {...props}\n >\n {children}\n <ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />\n </AccordionPrimitive.Trigger>\n </AccordionPrimitive.Header>\n )\n}\n\nfunction AccordionContent({\n className,\n children,\n ...props\n}: React.ComponentProps<typeof AccordionPrimitive.Content>) {\n return (\n <AccordionPrimitive.Content\n data-slot="accordion-content"\n className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"\n {...props}\n >\n <div className={cn("pt-0 pb-4", className)}>{children}</div>\n </AccordionPrimitive.Content>\n )\n}\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent }\n',
|
|
3799
3870
|
"marketing/payload/src/components/ui/button.tsx": 'import { cn } from "@/utilities/ui"\nimport { Slot } from "@radix-ui/react-slot"\nimport { type VariantProps, cva } from "class-variance-authority"\nimport type * as React from "react"\n\nconst buttonVariants = cva(\n "inline-flex items-center justify-center whitespace-nowrap rounded text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",\n {\n defaultVariants: {\n size: "default",\n variant: "default",\n },\n variants: {\n size: {\n clear: "",\n default: "h-10 px-4 py-2",\n icon: "h-10 w-10",\n lg: "h-11 rounded px-8",\n sm: "h-9 rounded px-3",\n },\n variant: {\n default: "bg-primary text-primary-foreground hover:bg-primary/90",\n destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",\n ghost: "hover:bg-card hover:text-accent-foreground",\n link: "text-primary items-start justify-start underline-offset-4 hover:underline",\n outline: "border border-border bg-background hover:bg-card hover:text-accent-foreground",\n secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",\n },\n },\n },\n)\n\nexport interface ButtonProps\n extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n VariantProps<typeof buttonVariants> {\n asChild?: boolean\n ref?: React.Ref<HTMLButtonElement>\n}\n\nconst Button: React.FC<ButtonProps> = ({\n asChild = false,\n className,\n size,\n variant,\n ref,\n ...props\n}) => {\n const Comp = asChild ? Slot : "button"\n return <Comp className={cn(buttonVariants({ className, size, variant }))} ref={ref} {...props} />\n}\n\nexport { Button, buttonVariants }\n',
|
|
3800
3871
|
"marketing/payload/src/components/ui/card.tsx": 'import { cn } from "@/utilities/ui"\nimport type * as React from "react"\n\nconst Card: React.FC<\n { ref?: React.Ref<HTMLDivElement> } & React.HTMLAttributes<HTMLDivElement>\n> = ({ className, ref, ...props }) => (\n <div\n className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}\n ref={ref}\n {...props}\n />\n)\n\nconst CardHeader: React.FC<\n { ref?: React.Ref<HTMLDivElement> } & React.HTMLAttributes<HTMLDivElement>\n> = ({ className, ref, ...props }) => (\n <div className={cn("flex flex-col space-y-1.5 p-6", className)} ref={ref} {...props} />\n)\n\nconst CardTitle: React.FC<\n { ref?: React.Ref<HTMLHeadingElement> } & React.HTMLAttributes<HTMLHeadingElement>\n> = ({ className, ref, ...props }) => (\n <h3\n className={cn("text-2xl font-semibold leading-none tracking-tight", className)}\n ref={ref}\n {...props}\n />\n)\n\nconst CardDescription: React.FC<\n { ref?: React.Ref<HTMLParagraphElement> } & React.HTMLAttributes<HTMLParagraphElement>\n> = ({ className, ref, ...props }) => (\n <p className={cn("text-sm text-muted-foreground", className)} ref={ref} {...props} />\n)\n\nconst CardContent: React.FC<\n { ref?: React.Ref<HTMLDivElement> } & React.HTMLAttributes<HTMLDivElement>\n> = ({ className, ref, ...props }) => (\n <div className={cn("p-6 pt-0", className)} ref={ref} {...props} />\n)\n\nconst CardFooter: React.FC<\n { ref?: React.Ref<HTMLDivElement> } & React.HTMLAttributes<HTMLDivElement>\n> = ({ className, ref, ...props }) => (\n <div className={cn("flex items-center p-6 pt-0", className)} ref={ref} {...props} />\n)\n\nexport { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }\n',
|
|
@@ -7964,698 +8035,7 @@ export const productPage = (): Partial<Page> => {
|
|
|
7964
8035
|
"marketing/payload/src/endpoints/seed/directoryhub/use-cases/index.ts": 'export { salesPage } from "./local-services"\nexport { marketingPage } from "./b2b-vendor-hubs"\nexport { productPage } from "./communities"\nexport { operationsPage } from "./marketplaces"\n',
|
|
7965
8036
|
"marketing/payload/src/endpoints/seed/directoryhub/use-cases/local-services.ts": 'import type { Page } from "@/payload-types"\nimport { createParagraph } from "../richtext-helper"\n\nexport const salesPage = (): Partial<Page> => {\n return {\n slug: "use-cases/sales",\n _status: "published",\n title: "SaaSify for Sales Teams",\n hero: {\n type: "lowImpact",\n richText: {\n root: {\n type: "root",\n children: [\n {\n type: "heading",\n children: [\n {\n type: "text",\n detail: 0,\n format: 0,\n mode: "normal",\n style: "",\n text: "Close more deals with less effort",\n version: 1,\n },\n ],\n direction: "ltr" as const,\n format: "" as const,\n indent: 0,\n tag: "h1",\n version: 1,\n },\n {\n type: "paragraph",\n children: [\n {\n type: "text",\n detail: 0,\n format: 0,\n mode: "normal",\n style: "",\n text: "Pipeline management, lead tracking, forecasting, and automation tools that help your sales team work smarter and close faster.",\n version: 1,\n },\n ],\n direction: "ltr" as const,\n format: "" as const,\n indent: 0,\n textFormat: 0,\n version: 1,\n },\n ],\n direction: "ltr" as const,\n format: "" as const,\n indent: 0,\n version: 1,\n },\n },\n links: [\n {\n link: {\n type: "custom",\n appearance: "default",\n label: "Start free trial",\n url: "/sign-up",\n },\n },\n {\n link: {\n type: "custom",\n appearance: "outline",\n label: "See a demo",\n url: "/demo",\n },\n },\n ],\n },\n layout: [\n {\n blockType: "featureShowcase",\n blockName: "Pipeline Management",\n label: "Visual Pipeline",\n headline: "See your entire pipeline at a glance",\n description: createParagraph(\n "Drag-and-drop deal management with customizable stages. Track deal values, probabilities, and expected close dates. Never let a deal slip through the cracks.",\n ),\n link: {\n type: "custom",\n label: "See pipeline features",\n url: "/features",\n appearance: "default",\n },\n imagePosition: "right",\n features: [\n { text: "Customizable deal stages" },\n { text: "Drag-and-drop interface" },\n { text: "Deal value tracking" },\n { text: "Win probability scoring" },\n ],\n },\n {\n blockType: "featureShowcase",\n blockName: "Lead Management",\n label: "Lead Tracking",\n headline: "Never lose track of a lead again",\n description: createParagraph(\n "Capture leads from any source, score them automatically, and route them to the right rep. Activity tracking shows every touchpoint so you always have context.",\n ),\n link: {\n type: "custom",\n label: "Learn about lead management",\n url: "/features",\n appearance: "default",\n },\n imagePosition: "left",\n features: [\n { text: "Lead capture from any source" },\n { text: "Automatic lead scoring" },\n { text: "Smart lead routing" },\n { text: "Complete activity history" },\n ],\n },\n {\n blockType: "featureShowcase",\n blockName: "Sales Automation",\n label: "Automation",\n headline: "Automate follow-ups and admin tasks",\n description: createParagraph(\n "Set up automated email sequences, task reminders, and deal updates. Spend less time on admin and more time selling.",\n ),\n link: {\n type: "custom",\n label: "See automation options",\n url: "/features/automation",\n appearance: "default",\n },\n imagePosition: "right",\n features: [\n { text: "Automated email sequences" },\n { text: "Task reminders and follow-ups" },\n { text: "Deal stage automation" },\n { text: "Meeting scheduling" },\n ],\n },\n {\n blockType: "bentoFeatures",\n blockName: "Sales Features",\n heading: "Tools that help you sell more",\n subheading: "Everything your sales team needs in one platform",\n features: [\n {\n size: "small",\n style: "gradient",\n icon: "barChart",\n stat: "40%",\n title: "Faster Deal Cycles",\n description: createParagraph("Close deals faster with better tools."),\n },\n {\n size: "small",\n style: "accent",\n icon: "users",\n title: "Contact Management",\n description: createParagraph("Complete contact and company records."),\n },\n {\n size: "small",\n style: "default",\n icon: "search",\n title: "Smart Search",\n description: createParagraph("Find any deal, contact, or activity instantly."),\n },\n {\n size: "small",\n style: "primary",\n icon: "target",\n title: "Forecasting",\n description: createParagraph("Accurate revenue forecasting and reporting."),\n },\n {\n size: "small",\n style: "default",\n icon: "zap",\n title: "Email Integration",\n description: createParagraph("Sync with Gmail and Outlook."),\n },\n {\n size: "small",\n style: "default",\n icon: "shield",\n title: "Activity Tracking",\n description: createParagraph("Log calls, emails, and meetings automatically."),\n },\n ],\n },\n {\n blockType: "proofBanner",\n blockName: "CTA Section",\n style: "centered",\n headline: "Give your sales team an unfair advantage",\n subtext:\n "Pipeline management, lead tracking, and automation. Start closing more deals today.",\n links: [\n {\n link: {\n type: "custom",\n appearance: "default",\n label: "Start free trial",\n url: "/sign-up",\n },\n },\n {\n link: {\n type: "custom",\n appearance: "outline",\n label: "Talk to sales",\n url: "/contact",\n },\n },\n ],\n },\n ],\n meta: {\n description:\n "SaaSify for sales teams. Pipeline management, lead tracking, forecasting, and automation tools to help you close more deals faster.",\n title: "Sales Teams \u2014 SaaSify Use Case",\n },\n }\n}\n',
|
|
7966
8037
|
"marketing/payload/src/endpoints/seed/directoryhub/use-cases/marketplaces.ts": 'import type { Page } from "@/payload-types"\nimport { createParagraph } from "../richtext-helper"\n\nexport const operationsPage = (): Partial<Page> => {\n return {\n slug: "use-cases/operations",\n _status: "published",\n title: "SaaSify for Operations Teams",\n hero: {\n type: "lowImpact",\n richText: {\n root: {\n type: "root",\n children: [\n {\n type: "heading",\n children: [\n {\n type: "text",\n detail: 0,\n format: 0,\n mode: "normal",\n style: "",\n text: "Scale your operations without the chaos",\n version: 1,\n },\n ],\n direction: "ltr" as const,\n format: "" as const,\n indent: 0,\n tag: "h1",\n version: 1,\n },\n {\n type: "paragraph",\n children: [\n {\n type: "text",\n detail: 0,\n format: 0,\n mode: "normal",\n style: "",\n text: "Process automation, resource planning, and reporting tools that help operations teams do more with less and scale efficiently.",\n version: 1,\n },\n ],\n direction: "ltr" as const,\n format: "" as const,\n indent: 0,\n textFormat: 0,\n version: 1,\n },\n ],\n direction: "ltr" as const,\n format: "" as const,\n indent: 0,\n version: 1,\n },\n },\n links: [\n {\n link: {\n type: "custom",\n appearance: "default",\n label: "Start free trial",\n url: "/sign-up",\n },\n },\n {\n link: {\n type: "custom",\n appearance: "outline",\n label: "See a demo",\n url: "/demo",\n },\n },\n ],\n },\n layout: [\n {\n blockType: "featureShowcase",\n blockName: "Process Automation",\n label: "Automation",\n headline: "Automate repetitive processes",\n description: createParagraph(\n "Build workflows that handle approvals, notifications, data routing, and handoffs automatically. Reduce manual work and eliminate bottlenecks.",\n ),\n link: {\n type: "custom",\n label: "See automation features",\n url: "/features/automation",\n appearance: "default",\n },\n imagePosition: "right",\n features: [\n { text: "Visual workflow builder" },\n { text: "Approval automation" },\n { text: "Data routing and handoffs" },\n { text: "Notification triggers" },\n ],\n },\n {\n blockType: "featureShowcase",\n blockName: "Resource Planning",\n label: "Resource Management",\n headline: "Plan and allocate resources effectively",\n description: createParagraph(\n "See team capacity, workload distribution, and resource utilization at a glance. Make informed decisions about hiring, outsourcing, and prioritization.",\n ),\n link: {\n type: "custom",\n label: "Learn about resource planning",\n url: "/features",\n appearance: "default",\n },\n imagePosition: "left",\n features: [\n { text: "Capacity planning" },\n { text: "Workload visibility" },\n { text: "Resource allocation" },\n { text: "Utilization tracking" },\n ],\n },\n {\n blockType: "featureShowcase",\n blockName: "Reporting",\n label: "Operations Analytics",\n headline: "Get visibility into every process",\n description: createParagraph(\n "Track cycle times, bottlenecks, and throughput. Custom dashboards and scheduled reports keep stakeholders informed and help you continuously improve.",\n ),\n link: {\n type: "custom",\n label: "See analytics features",\n url: "/features/analytics",\n appearance: "default",\n },\n imagePosition: "right",\n features: [\n { text: "Process analytics" },\n { text: "Cycle time tracking" },\n { text: "Bottleneck identification" },\n { text: "Custom dashboards" },\n ],\n },\n {\n blockType: "bentoFeatures",\n blockName: "Operations Features",\n heading: "Tools that help you scale",\n subheading: "Everything your operations team needs in one platform",\n features: [\n {\n size: "small",\n style: "gradient",\n icon: "zap",\n stat: "60%",\n title: "Time Saved",\n description: createParagraph("Automate away the busywork."),\n },\n {\n size: "small",\n style: "accent",\n icon: "layers",\n title: "Process Templates",\n description: createParagraph("Pre-built templates for common processes."),\n },\n {\n size: "small",\n style: "default",\n icon: "users",\n title: "Cross-Team Visibility",\n description: createParagraph("See work across departments."),\n },\n {\n size: "small",\n style: "primary",\n icon: "barChart",\n title: "Performance Metrics",\n description: createParagraph("Track KPIs across all processes."),\n },\n {\n size: "small",\n style: "default",\n icon: "database",\n title: "Data Integrations",\n description: createParagraph("Connect with your existing tools."),\n },\n {\n size: "small",\n style: "default",\n icon: "shield",\n title: "Compliance Tracking",\n description: createParagraph("Audit trails and compliance reporting."),\n },\n ],\n },\n {\n blockType: "proofBanner",\n blockName: "CTA Section",\n style: "centered",\n headline: "Scale operations without adding headcount",\n subtext:\n "Process automation, resource planning, and analytics. Start scaling smarter today.",\n links: [\n {\n link: {\n type: "custom",\n appearance: "default",\n label: "Start free trial",\n url: "/sign-up",\n },\n },\n {\n link: {\n type: "custom",\n appearance: "outline",\n label: "Talk to sales",\n url: "/contact",\n },\n },\n ],\n },\n ],\n meta: {\n description:\n "SaaSify for operations teams. Process automation, resource planning, and reporting tools to help you scale your operations efficiently.",\n title: "Operations Teams \u2014 SaaSify Use Case",\n },\n }\n}\n',
|
|
7967
|
-
"marketing/payload/src/endpoints/seed/home-static.ts":
|
|
7968
|
-
|
|
7969
|
-
// Helper to create simple paragraph rich text
|
|
7970
|
-
const createParagraph = (text: string) => ({
|
|
7971
|
-
root: {
|
|
7972
|
-
type: "root" as const,
|
|
7973
|
-
children: [
|
|
7974
|
-
{
|
|
7975
|
-
type: "paragraph" as const,
|
|
7976
|
-
children: [
|
|
7977
|
-
{
|
|
7978
|
-
type: "text" as const,
|
|
7979
|
-
detail: 0,
|
|
7980
|
-
format: 0,
|
|
7981
|
-
mode: "normal" as const,
|
|
7982
|
-
style: "",
|
|
7983
|
-
text,
|
|
7984
|
-
version: 1,
|
|
7985
|
-
},
|
|
7986
|
-
],
|
|
7987
|
-
direction: "ltr" as const,
|
|
7988
|
-
format: "" as const,
|
|
7989
|
-
indent: 0,
|
|
7990
|
-
textFormat: 0,
|
|
7991
|
-
version: 1,
|
|
7992
|
-
},
|
|
7993
|
-
],
|
|
7994
|
-
direction: "ltr" as const,
|
|
7995
|
-
format: "" as const,
|
|
7996
|
-
indent: 0,
|
|
7997
|
-
version: 1,
|
|
7998
|
-
},
|
|
7999
|
-
})
|
|
8000
|
-
|
|
8001
|
-
// Helper to create rich text with multiple elements
|
|
8002
|
-
type RichTextElement =
|
|
8003
|
-
| { type: "heading"; tag: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; text: string }
|
|
8004
|
-
| { type: "paragraph"; text: string }
|
|
8005
|
-
|
|
8006
|
-
const createRichText = (elements: RichTextElement[]) => ({
|
|
8007
|
-
root: {
|
|
8008
|
-
type: "root" as const,
|
|
8009
|
-
children: elements.map((el) => {
|
|
8010
|
-
if (el.type === "heading") {
|
|
8011
|
-
return {
|
|
8012
|
-
type: "heading" as const,
|
|
8013
|
-
tag: el.tag,
|
|
8014
|
-
children: [
|
|
8015
|
-
{
|
|
8016
|
-
type: "text" as const,
|
|
8017
|
-
detail: 0,
|
|
8018
|
-
format: 0,
|
|
8019
|
-
mode: "normal" as const,
|
|
8020
|
-
style: "",
|
|
8021
|
-
text: el.text,
|
|
8022
|
-
version: 1,
|
|
8023
|
-
},
|
|
8024
|
-
],
|
|
8025
|
-
direction: "ltr" as const,
|
|
8026
|
-
format: "" as const,
|
|
8027
|
-
indent: 0,
|
|
8028
|
-
version: 1,
|
|
8029
|
-
}
|
|
8030
|
-
}
|
|
8031
|
-
return {
|
|
8032
|
-
type: "paragraph" as const,
|
|
8033
|
-
children: [
|
|
8034
|
-
{
|
|
8035
|
-
type: "text" as const,
|
|
8036
|
-
detail: 0,
|
|
8037
|
-
format: 0,
|
|
8038
|
-
mode: "normal" as const,
|
|
8039
|
-
style: "",
|
|
8040
|
-
text: el.text,
|
|
8041
|
-
version: 1,
|
|
8042
|
-
},
|
|
8043
|
-
],
|
|
8044
|
-
direction: "ltr" as const,
|
|
8045
|
-
format: "" as const,
|
|
8046
|
-
indent: 0,
|
|
8047
|
-
textFormat: 0,
|
|
8048
|
-
version: 1,
|
|
8049
|
-
}
|
|
8050
|
-
}),
|
|
8051
|
-
direction: "ltr" as const,
|
|
8052
|
-
format: "" as const,
|
|
8053
|
-
indent: 0,
|
|
8054
|
-
version: 1,
|
|
8055
|
-
},
|
|
8056
|
-
})
|
|
8057
|
-
|
|
8058
|
-
// Used for pre-seeded content so that the homepage is not empty
|
|
8059
|
-
export const homeStatic: RequiredDataFromCollectionSlug<"pages"> = {
|
|
8060
|
-
slug: "home",
|
|
8061
|
-
_status: "published",
|
|
8062
|
-
hero: {
|
|
8063
|
-
type: "productShowcase",
|
|
8064
|
-
richText: {
|
|
8065
|
-
root: {
|
|
8066
|
-
type: "root",
|
|
8067
|
-
children: [
|
|
8068
|
-
{
|
|
8069
|
-
type: "heading",
|
|
8070
|
-
children: [
|
|
8071
|
-
{
|
|
8072
|
-
type: "text",
|
|
8073
|
-
detail: 0,
|
|
8074
|
-
format: 0,
|
|
8075
|
-
mode: "normal",
|
|
8076
|
-
style: "",
|
|
8077
|
-
text: "Launch, monetize, and scale directories fast",
|
|
8078
|
-
version: 1,
|
|
8079
|
-
},
|
|
8080
|
-
],
|
|
8081
|
-
direction: "ltr",
|
|
8082
|
-
format: "",
|
|
8083
|
-
indent: 0,
|
|
8084
|
-
tag: "h1",
|
|
8085
|
-
version: 1,
|
|
8086
|
-
},
|
|
8087
|
-
{
|
|
8088
|
-
type: "paragraph",
|
|
8089
|
-
children: [
|
|
8090
|
-
{
|
|
8091
|
-
type: "text",
|
|
8092
|
-
detail: 0,
|
|
8093
|
-
format: 0,
|
|
8094
|
-
mode: "normal",
|
|
8095
|
-
style: "",
|
|
8096
|
-
text: "The platform that accelerates directory launches, automates operations, and grows revenue.",
|
|
8097
|
-
version: 1,
|
|
8098
|
-
},
|
|
8099
|
-
],
|
|
8100
|
-
direction: "ltr",
|
|
8101
|
-
format: "",
|
|
8102
|
-
indent: 0,
|
|
8103
|
-
textFormat: 0,
|
|
8104
|
-
version: 1,
|
|
8105
|
-
},
|
|
8106
|
-
],
|
|
8107
|
-
direction: "ltr",
|
|
8108
|
-
format: "",
|
|
8109
|
-
indent: 0,
|
|
8110
|
-
version: 1,
|
|
8111
|
-
},
|
|
8112
|
-
},
|
|
8113
|
-
links: [
|
|
8114
|
-
{
|
|
8115
|
-
link: {
|
|
8116
|
-
type: "custom",
|
|
8117
|
-
newTab: false,
|
|
8118
|
-
url: "/sign-up",
|
|
8119
|
-
label: "Get started",
|
|
8120
|
-
appearance: "default",
|
|
8121
|
-
},
|
|
8122
|
-
},
|
|
8123
|
-
{
|
|
8124
|
-
link: {
|
|
8125
|
-
type: "custom",
|
|
8126
|
-
newTab: false,
|
|
8127
|
-
url: "/templates",
|
|
8128
|
-
label: "View templates",
|
|
8129
|
-
appearance: "outline",
|
|
8130
|
-
},
|
|
8131
|
-
},
|
|
8132
|
-
],
|
|
8133
|
-
},
|
|
8134
|
-
meta: {
|
|
8135
|
-
description:
|
|
8136
|
-
"Launch a profitable directory business in minutes with built-in payments, SEO, and automation. Free to start.",
|
|
8137
|
-
title: "DirectoryHub \u2014 Launch revenue-first directories fast",
|
|
8138
|
-
},
|
|
8139
|
-
title: "Home",
|
|
8140
|
-
layout: [
|
|
8141
|
-
// Logo Banner - Social proof strip (like Bird's Fortune 500 logos)
|
|
8142
|
-
{
|
|
8143
|
-
blockType: "logoBanner",
|
|
8144
|
-
heading: "Built for businesses where directories matter",
|
|
8145
|
-
style: "scroll",
|
|
8146
|
-
logos: [
|
|
8147
|
-
{ name: "Northwind Market" },
|
|
8148
|
-
{ name: "Acme Listings" },
|
|
8149
|
-
{ name: "Evergreen HQ" },
|
|
8150
|
-
{ name: "Atlas Network" },
|
|
8151
|
-
{ name: "Beacon Partners" },
|
|
8152
|
-
{ name: "Cascade Labs" },
|
|
8153
|
-
],
|
|
8154
|
-
},
|
|
8155
|
-
|
|
8156
|
-
// KPI Strip - Duna-style animated metrics
|
|
8157
|
-
{
|
|
8158
|
-
blockType: "proofBanner",
|
|
8159
|
-
style: "centered",
|
|
8160
|
-
headline: "Designed to convert. Built to scale.",
|
|
8161
|
-
subtext: "5x faster launches \u2022 99.9% uptime \u2022 $2M+ processed",
|
|
8162
|
-
},
|
|
8163
|
-
|
|
8164
|
-
// Value Pillars - Bird-style feature grid with icons
|
|
8165
|
-
{
|
|
8166
|
-
blockType: "featureGrid",
|
|
8167
|
-
heading: "Get to know DirectoryHub",
|
|
8168
|
-
subheading: "Everything you need to launch and monetize directories",
|
|
8169
|
-
columns: "3",
|
|
8170
|
-
features: [
|
|
8171
|
-
{
|
|
8172
|
-
icon: "zap",
|
|
8173
|
-
title: "Launch in days, not months",
|
|
8174
|
-
description: createParagraph(
|
|
8175
|
-
"Pick a template, add your brand, and publish. No backend setup, no DevOps headaches.",
|
|
8176
|
-
),
|
|
8177
|
-
},
|
|
8178
|
-
{
|
|
8179
|
-
icon: "dollarSign",
|
|
8180
|
-
title: "Monetize from day one",
|
|
8181
|
-
description: createParagraph(
|
|
8182
|
-
"Stripe-powered payments, subscription tiers, featured placements, and automated payouts.",
|
|
8183
|
-
),
|
|
8184
|
-
},
|
|
8185
|
-
{
|
|
8186
|
-
icon: "search",
|
|
8187
|
-
title: "SEO out of the box",
|
|
8188
|
-
description: createParagraph(
|
|
8189
|
-
"Structured data, sitemaps, and clean URLs generated automatically for every listing.",
|
|
8190
|
-
),
|
|
8191
|
-
},
|
|
8192
|
-
],
|
|
8193
|
-
},
|
|
8194
|
-
|
|
8195
|
-
// Product Feature 1 - Duna-style asymmetric content
|
|
8196
|
-
{
|
|
8197
|
-
blockType: "content",
|
|
8198
|
-
columns: [
|
|
8199
|
-
{
|
|
8200
|
-
size: "twoThirds",
|
|
8201
|
-
richText: createRichText([
|
|
8202
|
-
{
|
|
8203
|
-
type: "heading",
|
|
8204
|
-
tag: "h2",
|
|
8205
|
-
text: "Go from idea to live directory in a weekend",
|
|
8206
|
-
},
|
|
8207
|
-
{
|
|
8208
|
-
type: "paragraph",
|
|
8209
|
-
text: "High-converting directory templates \u2014 no code required. Benefit from schema pre-fills, dynamic UI, smart reminders, and intelligent optimizations.",
|
|
8210
|
-
},
|
|
8211
|
-
]),
|
|
8212
|
-
enableLink: true,
|
|
8213
|
-
link: {
|
|
8214
|
-
type: "custom",
|
|
8215
|
-
label: "See templates \u2192",
|
|
8216
|
-
url: "/templates",
|
|
8217
|
-
appearance: "default",
|
|
8218
|
-
},
|
|
8219
|
-
},
|
|
8220
|
-
{
|
|
8221
|
-
size: "oneThird",
|
|
8222
|
-
richText: createRichText([
|
|
8223
|
-
{ type: "paragraph", text: "\u2713 10+ ready-to-use templates" },
|
|
8224
|
-
{ type: "paragraph", text: "\u2713 Custom domain in minutes" },
|
|
8225
|
-
{ type: "paragraph", text: "\u2713 Mobile-first responsive design" },
|
|
8226
|
-
{ type: "paragraph", text: "\u2713 Deep localization support" },
|
|
8227
|
-
]),
|
|
8228
|
-
},
|
|
8229
|
-
],
|
|
8230
|
-
},
|
|
8231
|
-
|
|
8232
|
-
// How It Works - Visual walkthrough (Duna-style steps)
|
|
8233
|
-
{
|
|
8234
|
-
blockType: "howItWorks",
|
|
8235
|
-
heading: "The infrastructure behind every directory",
|
|
8236
|
-
subheading:
|
|
8237
|
-
"At the heart of DirectoryHub is a powerful engine driving decisions across the full customer lifecycle.",
|
|
8238
|
-
steps: [
|
|
8239
|
-
{
|
|
8240
|
-
title: "Choose & customize",
|
|
8241
|
-
description: createParagraph(
|
|
8242
|
-
"Select from professionally designed templates. Configure every field to fit your niche \u2014 from basic listings to complex schemas.",
|
|
8243
|
-
),
|
|
8244
|
-
},
|
|
8245
|
-
{
|
|
8246
|
-
title: "Configure monetization",
|
|
8247
|
-
description: createParagraph(
|
|
8248
|
-
"Set up subscription tiers, featured placements, and pay-per-listing. Stripe handles payments, we handle the rest.",
|
|
8249
|
-
),
|
|
8250
|
-
},
|
|
8251
|
-
{
|
|
8252
|
-
title: "Launch & scale",
|
|
8253
|
-
description: createParagraph(
|
|
8254
|
-
"Publish your directory with SEO baked in. Monitor analytics, automate moderation, and grow your audience.",
|
|
8255
|
-
),
|
|
8256
|
-
},
|
|
8257
|
-
],
|
|
8258
|
-
},
|
|
8259
|
-
|
|
8260
|
-
// Product Feature 2 - Monetization (Duna-style reversed layout)
|
|
8261
|
-
{
|
|
8262
|
-
blockType: "content",
|
|
8263
|
-
columns: [
|
|
8264
|
-
{
|
|
8265
|
-
size: "oneThird",
|
|
8266
|
-
richText: createRichText([
|
|
8267
|
-
{ type: "paragraph", text: "\u{1F4B3} Subscription tiers" },
|
|
8268
|
-
{ type: "paragraph", text: "\u2B50 Featured placements" },
|
|
8269
|
-
{ type: "paragraph", text: "\u{1F4DD} Pay-per-listing" },
|
|
8270
|
-
{ type: "paragraph", text: "\u{1F3AB} Coupon codes" },
|
|
8271
|
-
]),
|
|
8272
|
-
},
|
|
8273
|
-
{
|
|
8274
|
-
size: "twoThirds",
|
|
8275
|
-
richText: createRichText([
|
|
8276
|
-
{ type: "heading", tag: "h2", text: "Drive revenue with built-in monetization" },
|
|
8277
|
-
{
|
|
8278
|
-
type: "paragraph",
|
|
8279
|
-
text: "Stop leaving money on the table. Charge for listings, offer premium placements, and run subscription tiers without writing a line of payment code.",
|
|
8280
|
-
},
|
|
8281
|
-
{
|
|
8282
|
-
type: "paragraph",
|
|
8283
|
-
text: "Automated invoicing, tax handling, and payout scheduling included.",
|
|
8284
|
-
},
|
|
8285
|
-
]),
|
|
8286
|
-
enableLink: true,
|
|
8287
|
-
link: {
|
|
8288
|
-
type: "custom",
|
|
8289
|
-
label: "Learn about monetization \u2192",
|
|
8290
|
-
url: "/pricing",
|
|
8291
|
-
appearance: "default",
|
|
8292
|
-
},
|
|
8293
|
-
},
|
|
8294
|
-
],
|
|
8295
|
-
},
|
|
8296
|
-
|
|
8297
|
-
// Use Cases - Bird-style vertical cards
|
|
8298
|
-
{
|
|
8299
|
-
blockType: "personas",
|
|
8300
|
-
heading: "Directories that turn data into intelligent experiences",
|
|
8301
|
-
subheading:
|
|
8302
|
-
"Whether you are building for local services or global marketplaces, DirectoryHub adapts to your model.",
|
|
8303
|
-
personas: [
|
|
8304
|
-
{
|
|
8305
|
-
icon: "briefcase",
|
|
8306
|
-
title: "Local Services",
|
|
8307
|
-
description: createParagraph(
|
|
8308
|
-
"Plumbers, photographers, restaurants. Card grids with reviews, maps, and geo-filtering.",
|
|
8309
|
-
),
|
|
8310
|
-
},
|
|
8311
|
-
{
|
|
8312
|
-
icon: "building",
|
|
8313
|
-
title: "B2B Vendor Hubs",
|
|
8314
|
-
description: createParagraph(
|
|
8315
|
-
"SaaS tools, agencies, consultants. Advanced filters, comparison views, and lead capture.",
|
|
8316
|
-
),
|
|
8317
|
-
},
|
|
8318
|
-
{
|
|
8319
|
-
icon: "users",
|
|
8320
|
-
title: "Communities",
|
|
8321
|
-
description: createParagraph(
|
|
8322
|
-
"Member directories, alumni networks, professional associations with gated access.",
|
|
8323
|
-
),
|
|
8324
|
-
},
|
|
8325
|
-
{
|
|
8326
|
-
icon: "trendingUp",
|
|
8327
|
-
title: "Marketplaces",
|
|
8328
|
-
description: createParagraph(
|
|
8329
|
-
"Multi-vendor search, featured slots, automated payouts to sellers.",
|
|
8330
|
-
),
|
|
8331
|
-
},
|
|
8332
|
-
],
|
|
8333
|
-
},
|
|
8334
|
-
|
|
8335
|
-
// Testimonials Header
|
|
8336
|
-
{
|
|
8337
|
-
blockType: "content",
|
|
8338
|
-
columns: [
|
|
8339
|
-
{
|
|
8340
|
-
size: "full",
|
|
8341
|
-
richText: createRichText([
|
|
8342
|
-
{ type: "heading", tag: "h2", text: "Trusted by founders who depend on their data" },
|
|
8343
|
-
{
|
|
8344
|
-
type: "paragraph",
|
|
8345
|
-
text: "See how leading directory builders use DirectoryHub to drive intelligent growth.",
|
|
8346
|
-
},
|
|
8347
|
-
]),
|
|
8348
|
-
},
|
|
8349
|
-
],
|
|
8350
|
-
},
|
|
8351
|
-
|
|
8352
|
-
// Testimonials - Bird-style stats cards
|
|
8353
|
-
{
|
|
8354
|
-
blockType: "featureGrid",
|
|
8355
|
-
columns: "3",
|
|
8356
|
-
features: [
|
|
8357
|
-
{
|
|
8358
|
-
icon: "star",
|
|
8359
|
-
title: "94% faster launch time",
|
|
8360
|
-
description: createParagraph(
|
|
8361
|
-
'"We went from idea to collecting payments in 3 days. The templates and Stripe integration saved us months." \u2014 Sarah Chen, Northwind Market',
|
|
8362
|
-
),
|
|
8363
|
-
},
|
|
8364
|
-
{
|
|
8365
|
-
icon: "star",
|
|
8366
|
-
title: "Page 1 rankings in weeks",
|
|
8367
|
-
description: createParagraph(
|
|
8368
|
-
'"Our niche directory started ranking on page 1 within weeks. The structured data and sitemaps are handled automatically." \u2014 Marcus Rivera, Beacon Partners',
|
|
8369
|
-
),
|
|
8370
|
-
},
|
|
8371
|
-
{
|
|
8372
|
-
icon: "star",
|
|
8373
|
-
title: "$12K MRR in month one",
|
|
8374
|
-
description: createParagraph(
|
|
8375
|
-
'"Featured placements and premium tiers covered our costs in the first month. The monetization tools just work." \u2014 David Kim, Cascade Labs',
|
|
8376
|
-
),
|
|
8377
|
-
},
|
|
8378
|
-
],
|
|
8379
|
-
},
|
|
8380
|
-
|
|
8381
|
-
// Social Proof Banner - Full-width CTA (Duna-style)
|
|
8382
|
-
{
|
|
8383
|
-
blockType: "proofBanner",
|
|
8384
|
-
style: "withBackground",
|
|
8385
|
-
headline: "Join 500+ founders building revenue-first directories",
|
|
8386
|
-
subtext:
|
|
8387
|
-
"Ship faster than custom builds. Keep the polish, security, and governance your users expect.",
|
|
8388
|
-
links: [
|
|
8389
|
-
{
|
|
8390
|
-
link: {
|
|
8391
|
-
type: "custom",
|
|
8392
|
-
newTab: false,
|
|
8393
|
-
url: "/sign-up",
|
|
8394
|
-
label: "Get started",
|
|
8395
|
-
appearance: "outline",
|
|
8396
|
-
},
|
|
8397
|
-
},
|
|
8398
|
-
{
|
|
8399
|
-
link: {
|
|
8400
|
-
type: "custom",
|
|
8401
|
-
newTab: false,
|
|
8402
|
-
url: "/posts",
|
|
8403
|
-
label: "See customer stories",
|
|
8404
|
-
appearance: "default",
|
|
8405
|
-
},
|
|
8406
|
-
},
|
|
8407
|
-
],
|
|
8408
|
-
},
|
|
8409
|
-
|
|
8410
|
-
// Trust & Security - Duna-style two-column
|
|
8411
|
-
{
|
|
8412
|
-
blockType: "content",
|
|
8413
|
-
columns: [
|
|
8414
|
-
{
|
|
8415
|
-
size: "half",
|
|
8416
|
-
richText: createRichText([
|
|
8417
|
-
{ type: "heading", tag: "h2", text: "Safe and secure" },
|
|
8418
|
-
{
|
|
8419
|
-
type: "paragraph",
|
|
8420
|
-
text: "Your trust is our foundation. DirectoryHub is designed with a deep commitment to data privacy and security.",
|
|
8421
|
-
},
|
|
8422
|
-
{ type: "paragraph", text: "\u{1F512} SOC 2-minded controls" },
|
|
8423
|
-
{ type: "paragraph", text: "\u{1F510} Per-tenant data isolation" },
|
|
8424
|
-
{ type: "paragraph", text: "\u{1F4CB} Complete audit trails" },
|
|
8425
|
-
{ type: "paragraph", text: "\u{1F30D} GDPR-ready infrastructure" },
|
|
8426
|
-
]),
|
|
8427
|
-
},
|
|
8428
|
-
{
|
|
8429
|
-
size: "half",
|
|
8430
|
-
richText: createRichText([
|
|
8431
|
-
{ type: "heading", tag: "h2", text: "Enterprise-ready infrastructure" },
|
|
8432
|
-
{
|
|
8433
|
-
type: "paragraph",
|
|
8434
|
-
text: "We partner with the best so you do not have to worry about uptime, scaling, or reliability.",
|
|
8435
|
-
},
|
|
8436
|
-
{ type: "paragraph", text: "\u26A1 99.9% uptime SLA" },
|
|
8437
|
-
{ type: "paragraph", text: "\u{1F680} Global CDN delivery" },
|
|
8438
|
-
{ type: "paragraph", text: "\u{1F4BE} Automated backups" },
|
|
8439
|
-
{ type: "paragraph", text: "\u{1F4C8} Auto-scaling infrastructure" },
|
|
8440
|
-
]),
|
|
8441
|
-
},
|
|
8442
|
-
],
|
|
8443
|
-
},
|
|
8444
|
-
|
|
8445
|
-
// Integrations Wall - Bird-style
|
|
8446
|
-
{
|
|
8447
|
-
blockType: "logoBanner",
|
|
8448
|
-
heading: "Connect anywhere. Plug in and get started immediately.",
|
|
8449
|
-
style: "grid",
|
|
8450
|
-
logos: [
|
|
8451
|
-
{ name: "Stripe" },
|
|
8452
|
-
{ name: "Vercel" },
|
|
8453
|
-
{ name: "AWS" },
|
|
8454
|
-
{ name: "Google Analytics" },
|
|
8455
|
-
{ name: "Zapier" },
|
|
8456
|
-
{ name: "Convex" },
|
|
8457
|
-
],
|
|
8458
|
-
},
|
|
8459
|
-
|
|
8460
|
-
// Pricing
|
|
8461
|
-
{
|
|
8462
|
-
blockType: "pricingTable",
|
|
8463
|
-
heading: "Simple, transparent pricing",
|
|
8464
|
-
subheading: "Start free, upgrade as you grow. No hidden fees.",
|
|
8465
|
-
showComparisonTable: false,
|
|
8466
|
-
showViewAllLink: true,
|
|
8467
|
-
maxFeaturesOnCard: 4,
|
|
8468
|
-
plans: [
|
|
8469
|
-
{
|
|
8470
|
-
name: "Free",
|
|
8471
|
-
price: "$0/mo",
|
|
8472
|
-
description: "Perfect for getting started.",
|
|
8473
|
-
features: [
|
|
8474
|
-
{ feature: "1 directory site", included: true },
|
|
8475
|
-
{ feature: "Basic customization", included: true },
|
|
8476
|
-
{ feature: "Community support", included: true },
|
|
8477
|
-
],
|
|
8478
|
-
link: {
|
|
8479
|
-
type: "custom",
|
|
8480
|
-
label: "Get started free",
|
|
8481
|
-
url: "/sign-up",
|
|
8482
|
-
appearance: "outline",
|
|
8483
|
-
},
|
|
8484
|
-
},
|
|
8485
|
-
{
|
|
8486
|
-
name: "Pro",
|
|
8487
|
-
price: "$29/mo",
|
|
8488
|
-
description: "For growing businesses.",
|
|
8489
|
-
featured: true,
|
|
8490
|
-
features: [
|
|
8491
|
-
{ feature: "5 directory sites", included: true },
|
|
8492
|
-
{ feature: "Advanced customization", included: true },
|
|
8493
|
-
{ feature: "Custom domains", included: true },
|
|
8494
|
-
{ feature: "Priority support", included: true },
|
|
8495
|
-
],
|
|
8496
|
-
link: {
|
|
8497
|
-
type: "custom",
|
|
8498
|
-
label: "Get Pro",
|
|
8499
|
-
url: "/sign-up",
|
|
8500
|
-
appearance: "default",
|
|
8501
|
-
},
|
|
8502
|
-
},
|
|
8503
|
-
{
|
|
8504
|
-
name: "Business",
|
|
8505
|
-
price: "$99/mo",
|
|
8506
|
-
description: "For teams and agencies.",
|
|
8507
|
-
features: [
|
|
8508
|
-
{ feature: "Unlimited directory sites", included: true },
|
|
8509
|
-
{ feature: "White-label branding", included: true },
|
|
8510
|
-
{ feature: "API access", included: true },
|
|
8511
|
-
{ feature: "Dedicated support", included: true },
|
|
8512
|
-
],
|
|
8513
|
-
link: {
|
|
8514
|
-
type: "custom",
|
|
8515
|
-
label: "Get Business",
|
|
8516
|
-
url: "/sign-up",
|
|
8517
|
-
appearance: "outline",
|
|
8518
|
-
},
|
|
8519
|
-
},
|
|
8520
|
-
],
|
|
8521
|
-
},
|
|
8522
|
-
|
|
8523
|
-
// FAQ Header
|
|
8524
|
-
{
|
|
8525
|
-
blockType: "content",
|
|
8526
|
-
columns: [
|
|
8527
|
-
{
|
|
8528
|
-
size: "full",
|
|
8529
|
-
richText: createRichText([
|
|
8530
|
-
{ type: "heading", tag: "h2", text: "Frequently asked questions" },
|
|
8531
|
-
{ type: "paragraph", text: "Everything you need to know about DirectoryHub." },
|
|
8532
|
-
]),
|
|
8533
|
-
},
|
|
8534
|
-
],
|
|
8535
|
-
},
|
|
8536
|
-
|
|
8537
|
-
// FAQ Content - Two columns
|
|
8538
|
-
{
|
|
8539
|
-
blockType: "content",
|
|
8540
|
-
columns: [
|
|
8541
|
-
{
|
|
8542
|
-
size: "half",
|
|
8543
|
-
richText: createRichText([
|
|
8544
|
-
{ type: "heading", tag: "h4", text: "How fast can I launch?" },
|
|
8545
|
-
{
|
|
8546
|
-
type: "paragraph",
|
|
8547
|
-
text: "Most teams go live in a weekend. Pick a template, add your brand, configure monetization, and publish.",
|
|
8548
|
-
},
|
|
8549
|
-
{ type: "heading", tag: "h4", text: "Do you handle SEO?" },
|
|
8550
|
-
{
|
|
8551
|
-
type: "paragraph",
|
|
8552
|
-
text: "Yes. Structured data, sitemaps, meta tags, and clean URLs are all generated automatically.",
|
|
8553
|
-
},
|
|
8554
|
-
{ type: "heading", tag: "h4", text: "Can I use my own domain?" },
|
|
8555
|
-
{
|
|
8556
|
-
type: "paragraph",
|
|
8557
|
-
text: "Absolutely. Custom domains are supported on all paid plans, with automatic SSL certificates.",
|
|
8558
|
-
},
|
|
8559
|
-
]),
|
|
8560
|
-
},
|
|
8561
|
-
{
|
|
8562
|
-
size: "half",
|
|
8563
|
-
richText: createRichText([
|
|
8564
|
-
{ type: "heading", tag: "h4", text: "How do payments work?" },
|
|
8565
|
-
{
|
|
8566
|
-
type: "paragraph",
|
|
8567
|
-
text: "We integrate with Stripe. You can charge for listings, offer subscriptions, and receive automated payouts.",
|
|
8568
|
-
},
|
|
8569
|
-
{ type: "heading", tag: "h4", text: "Can I moderate submissions?" },
|
|
8570
|
-
{
|
|
8571
|
-
type: "paragraph",
|
|
8572
|
-
text: "Yes. Built-in review queues, approval workflows, and auto-moderation tools keep your directory quality high.",
|
|
8573
|
-
},
|
|
8574
|
-
{ type: "heading", tag: "h4", text: "What if I need help?" },
|
|
8575
|
-
{
|
|
8576
|
-
type: "paragraph",
|
|
8577
|
-
text: "Community support on Free, priority support on Pro, and dedicated support on Business plans.",
|
|
8578
|
-
},
|
|
8579
|
-
]),
|
|
8580
|
-
},
|
|
8581
|
-
],
|
|
8582
|
-
},
|
|
8583
|
-
|
|
8584
|
-
// Final CTA
|
|
8585
|
-
{
|
|
8586
|
-
blockType: "cta",
|
|
8587
|
-
richText: {
|
|
8588
|
-
root: {
|
|
8589
|
-
type: "root",
|
|
8590
|
-
children: [
|
|
8591
|
-
{
|
|
8592
|
-
type: "heading",
|
|
8593
|
-
tag: "h2",
|
|
8594
|
-
children: [
|
|
8595
|
-
{
|
|
8596
|
-
type: "text",
|
|
8597
|
-
detail: 0,
|
|
8598
|
-
format: 0,
|
|
8599
|
-
mode: "normal",
|
|
8600
|
-
style: "",
|
|
8601
|
-
text: "Ready to launch your directory?",
|
|
8602
|
-
version: 1,
|
|
8603
|
-
},
|
|
8604
|
-
],
|
|
8605
|
-
direction: "ltr",
|
|
8606
|
-
format: "",
|
|
8607
|
-
indent: 0,
|
|
8608
|
-
version: 1,
|
|
8609
|
-
},
|
|
8610
|
-
{
|
|
8611
|
-
type: "paragraph",
|
|
8612
|
-
children: [
|
|
8613
|
-
{
|
|
8614
|
-
type: "text",
|
|
8615
|
-
detail: 0,
|
|
8616
|
-
format: 0,
|
|
8617
|
-
mode: "normal",
|
|
8618
|
-
style: "",
|
|
8619
|
-
text: "Join hundreds of founders who chose the faster path to a profitable directory business.",
|
|
8620
|
-
version: 1,
|
|
8621
|
-
},
|
|
8622
|
-
],
|
|
8623
|
-
direction: "ltr",
|
|
8624
|
-
format: "",
|
|
8625
|
-
indent: 0,
|
|
8626
|
-
version: 1,
|
|
8627
|
-
},
|
|
8628
|
-
],
|
|
8629
|
-
direction: "ltr",
|
|
8630
|
-
format: "",
|
|
8631
|
-
indent: 0,
|
|
8632
|
-
version: 1,
|
|
8633
|
-
},
|
|
8634
|
-
},
|
|
8635
|
-
links: [
|
|
8636
|
-
{
|
|
8637
|
-
link: {
|
|
8638
|
-
type: "custom",
|
|
8639
|
-
newTab: false,
|
|
8640
|
-
url: "/sign-up",
|
|
8641
|
-
label: "Get started",
|
|
8642
|
-
appearance: "default",
|
|
8643
|
-
},
|
|
8644
|
-
},
|
|
8645
|
-
{
|
|
8646
|
-
link: {
|
|
8647
|
-
type: "custom",
|
|
8648
|
-
newTab: false,
|
|
8649
|
-
url: "/contact",
|
|
8650
|
-
label: "Book a demo",
|
|
8651
|
-
appearance: "outline",
|
|
8652
|
-
},
|
|
8653
|
-
},
|
|
8654
|
-
],
|
|
8655
|
-
},
|
|
8656
|
-
],
|
|
8657
|
-
}
|
|
8658
|
-
`,
|
|
8038
|
+
"marketing/payload/src/endpoints/seed/home-static.ts": 'import type { RequiredDataFromCollectionSlug } from "payload"\n\n// Minimal setup page shown before the database is seeded\n// This page guides users to complete their site setup\nexport const homeStatic: RequiredDataFromCollectionSlug<"pages"> = {\n slug: "home",\n _status: "published",\n hero: {\n type: "lowImpact",\n richText: {\n root: {\n type: "root",\n children: [\n {\n type: "heading",\n children: [\n {\n type: "text",\n detail: 0,\n format: 0,\n mode: "normal",\n style: "",\n text: "Welcome to your new site",\n version: 1,\n },\n ],\n direction: "ltr",\n format: "",\n indent: 0,\n tag: "h1",\n version: 1,\n },\n {\n type: "paragraph",\n children: [\n {\n type: "text",\n detail: 0,\n format: 0,\n mode: "normal",\n style: "",\n text: "Your site is ready to be configured. Visit the admin panel to set up your content.",\n version: 1,\n },\n ],\n direction: "ltr",\n format: "",\n indent: 0,\n textFormat: 0,\n version: 1,\n },\n ],\n direction: "ltr",\n format: "",\n indent: 0,\n version: 1,\n },\n },\n links: [\n {\n link: {\n type: "custom",\n newTab: false,\n url: "/admin",\n label: "Go to Admin Panel",\n appearance: "default",\n },\n },\n ],\n },\n meta: {\n description: "Welcome to your new site. Set up your content in the admin panel.",\n title: "Welcome \u2014 Site Setup",\n },\n title: "Home",\n layout: [\n {\n blockType: "content",\n columns: [\n {\n size: "full",\n richText: {\n root: {\n type: "root",\n children: [\n {\n type: "heading",\n tag: "h2",\n children: [\n {\n type: "text",\n detail: 0,\n format: 0,\n mode: "normal",\n style: "",\n text: "Getting Started",\n version: 1,\n },\n ],\n direction: "ltr",\n format: "",\n indent: 0,\n version: 1,\n },\n {\n type: "paragraph",\n children: [\n {\n type: "text",\n detail: 0,\n format: 0,\n mode: "normal",\n style: "",\n text: "1. Go to ",\n version: 1,\n },\n {\n type: "text",\n detail: 0,\n format: 1,\n mode: "normal",\n style: "",\n text: "/admin",\n version: 1,\n },\n {\n type: "text",\n detail: 0,\n format: 0,\n mode: "normal",\n style: "",\n text: " and create your admin account",\n version: 1,\n },\n ],\n direction: "ltr",\n format: "",\n indent: 0,\n textFormat: 0,\n version: 1,\n },\n {\n type: "paragraph",\n children: [\n {\n type: "text",\n detail: 0,\n format: 0,\n mode: "normal",\n style: "",\n text: "2. Click the \\"Seed Database\\" button to populate your site with demo content",\n version: 1,\n },\n ],\n direction: "ltr",\n format: "",\n indent: 0,\n textFormat: 0,\n version: 1,\n },\n {\n type: "paragraph",\n children: [\n {\n type: "text",\n detail: 0,\n format: 0,\n mode: "normal",\n style: "",\n text: "3. Customize your pages, navigation, and content",\n version: 1,\n },\n ],\n direction: "ltr",\n format: "",\n indent: 0,\n textFormat: 0,\n version: 1,\n },\n ],\n direction: "ltr",\n format: "",\n indent: 0,\n version: 1,\n },\n },\n },\n ],\n },\n ],\n}\n',
|
|
8659
8039
|
"marketing/payload/src/endpoints/seed/home.ts": `import type { Media } from "@/payload-types"
|
|
8660
8040
|
import type { RequiredDataFromCollectionSlug } from "payload"
|
|
8661
8041
|
|
|
@@ -10167,7 +9547,7 @@ export const post3: (args: PostArgs) => RequiredDataFromCollectionSlug<"posts">
|
|
|
10167
9547
|
"marketing/payload/src/heros/LowImpact/index.tsx": 'import type React from "react"\n\nimport type { Page } from "@/payload-types"\n\nimport { CMSLink } from "@/components/Link"\nimport RichText from "@/components/RichText"\n\ntype LowImpactHeroType =\n | {\n children?: React.ReactNode\n richText?: never\n links?: never\n }\n | (Omit<Page["hero"], "richText"> & {\n children?: never\n richText?: Page["hero"]["richText"]\n })\n\nexport const LowImpactHero: React.FC<LowImpactHeroType> = ({ children, richText, links }) => {\n return (\n <div className="py-20 md:py-32">\n <div className="container mx-auto px-4">\n <div className="text-center max-w-4xl mx-auto">\n {children ||\n (richText && (\n <RichText\n className="mb-8 hero-content"\n data={richText}\n enableGutter={false}\n enableProse={false}\n />\n ))}\n {Array.isArray(links) && links.length > 0 && (\n <ul className="flex flex-wrap justify-center gap-4">\n {links.map(({ link }, i) => {\n return (\n <li key={i}>\n <CMSLink {...link} size="lg" />\n </li>\n )\n })}\n </ul>\n )}\n </div>\n </div>\n </div>\n )\n}\n',
|
|
10168
9548
|
"marketing/payload/src/heros/MediumImpact/index.tsx": '"use client"\n\nimport type React from "react"\n\nimport type { Page } from "@/payload-types"\n\nimport { CTATracker } from "@/components/Analytics"\nimport { CMSLink } from "@/components/Link"\nimport { Media } from "@/components/Media"\nimport RichText from "@/components/RichText"\n\nexport const MediumImpactHero: React.FC<Page["hero"]> = ({ links, media, richText }) => {\n return (\n <div className="py-20 md:py-28">\n <div className="container mx-auto px-4">\n <div className="text-center max-w-4xl mx-auto mb-12 hero-content">\n {richText && (\n <RichText className="mb-8" data={richText} enableGutter={false} enableProse={false} />\n )}\n\n {Array.isArray(links) && links.length > 0 && (\n <ul className="flex flex-wrap justify-center gap-4">\n {links.map(({ link }, i) => {\n return (\n // biome-ignore lint/suspicious/noArrayIndexKey: CMS links don\'t have stable IDs\n <li key={i}>\n <CTATracker location="hero_medium_impact" variant={link?.label || `cta_${i}`}>\n <CMSLink {...link} size="lg" />\n </CTATracker>\n </li>\n )\n })}\n </ul>\n )}\n </div>\n\n {media && typeof media === "object" && (\n <div className="rounded-xl overflow-hidden border border-border shadow-2xl">\n <Media imgClassName="w-full" priority resource={media} />\n {media?.caption && (\n <div className="mt-3 text-center">\n <RichText data={media.caption} enableGutter={false} />\n </div>\n )}\n </div>\n )}\n </div>\n </div>\n )\n}\n',
|
|
10169
9549
|
"marketing/payload/src/heros/PostHero/index.tsx": 'import type React from "react"\nimport { formatDateTime } from "src/utilities/formatDateTime"\n\nimport type { Post } from "@/payload-types"\n\nimport { formatAuthors } from "@/utilities/formatAuthors"\nimport Link from "next/link"\n\nexport const PostHero: React.FC<{\n post: Post\n}> = ({ post }) => {\n const { categories, populatedAuthors, publishedAt, title } = post\n\n const hasAuthors =\n populatedAuthors && populatedAuthors.length > 0 && formatAuthors(populatedAuthors) !== ""\n\n return (\n <div\n className="relative"\n style={{\n background: "linear-gradient(180deg, #0F1F3D 0%, #101F3C 50%, #0F1F3D 100%)",\n }}\n >\n <div className="container py-16 md:py-20 lg:py-24">\n <div className="max-w-3xl">\n {/* Category Badge */}\n {categories && categories.length > 0 && (\n <div className="flex items-center gap-2 mb-6">\n {categories.map((category) => {\n if (typeof category === "object" && category !== null) {\n const { title: categoryTitle, slug, id } = category\n const titleToUse = categoryTitle || "Untitled category"\n\n return (\n <Link\n key={id || slug}\n href={`/posts?category=${slug}`}\n className="inline-flex items-center px-3 py-1 text-xs font-medium uppercase tracking-wider text-[#3DA9A3] bg-[#3DA9A3]/10 rounded-full hover:bg-[#3DA9A3]/20 transition-colors"\n >\n {titleToUse}\n </Link>\n )\n }\n return null\n })}\n </div>\n )}\n\n {/* Title */}\n <h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white leading-tight mb-8">\n {title}\n </h1>\n\n {/* Meta Information */}\n <div className="flex items-center gap-6 text-sm text-white/70">\n {hasAuthors && (\n <div className="flex items-center gap-2">\n <span className="text-white/50">By</span>\n <span className="text-white font-medium">{formatAuthors(populatedAuthors)}</span>\n </div>\n )}\n {hasAuthors && publishedAt && <span className="text-white/30">\u2022</span>}\n {publishedAt && (\n <time dateTime={publishedAt} className="text-white/70">\n {formatDateTime(publishedAt)}\n </time>\n )}\n </div>\n </div>\n </div>\n </div>\n )\n}\n',
|
|
10170
|
-
"marketing/payload/src/heros/ProductShowcase/AnimatedMockup.tsx": '"use client"\n\nimport { cn } from "@/utilities/ui"\nimport type React from "react"\nimport { useEffect, useState } from "react"\n\ninterface MockupState {\n id: number\n label: string\n sidebarActive: string\n previewTitle: string\n previewDescription: string\n previewCategory: string\n previewStatus: "draft" | "published" | "featured"\n previewUrl?: string\n}\n\nconst mockupStates: MockupState[] = [\n {\n id: 1,\n label: "Setup & styling",\n sidebarActive: "templates",\n previewTitle: "Atlas Directory",\n previewDescription: "Apply your brand, typography, and layout in minutes.",\n previewCategory: "Design systems",\n previewStatus: "draft",\n previewUrl: "atlas.directory/home",\n },\n {\n id: 2,\n label: "Plans & pricing",\n sidebarActive: "billing",\n previewTitle: "Pro Listing Plan",\n previewDescription: "Recurring billing, featured placements, and add-ons configured.",\n previewCategory: "Monetization",\n previewStatus: "draft",\n previewUrl: "atlas.directory/billing",\n },\n {\n id: 3,\n label: "SEO & publishing",\n sidebarActive: "seo",\n previewTitle: "Atlas Directory",\n previewDescription: "Schema, sitemap, and custom domain are ready to publish.",\n previewCategory: "SEO & domains",\n previewStatus: "published",\n previewUrl: "atlas.directory/launch",\n },\n {\n id: 4,\n label: "Payouts live",\n sidebarActive: "overview",\n previewTitle: "Atlas Directory",\n previewDescription: "Subscribers active, payouts scheduled to Stripe, featured slots sold.",\n previewCategory: "Revenue",\n previewStatus: "featured",\n previewUrl: "atlas.directory/analytics",\n },\n]\n\nexport const AnimatedMockup: React.FC = () => {\n const [currentState, setCurrentState] = useState(0)\n const [isPaused, setIsPaused] = useState(false)\n\n useEffect(() => {\n if (isPaused) return\n\n const interval = setInterval(() => {\n setCurrentState((prev) => (prev + 1) % mockupStates.length)\n }, 3000)\n\n return () => clearInterval(interval)\n }, [isPaused])\n\n const state = mockupStates[currentState]\n\n return (\n <div\n className="mockup-wrapper"\n onMouseEnter={() => setIsPaused(true)}\n onMouseLeave={() => setIsPaused(false)}\n >\n {/* Browser Chrome */}\n <div className="mockup-chrome">\n <div className="mockup-chrome-dots">\n <span className="dot dot-red" />\n <span className="dot dot-yellow" />\n <span className="dot dot-green" />\n </div>\n <div className="mockup-chrome-title">
|
|
9550
|
+
"marketing/payload/src/heros/ProductShowcase/AnimatedMockup.tsx": '"use client"\n\nimport { cn } from "@/utilities/ui"\nimport type React from "react"\nimport { useEffect, useState } from "react"\n\ninterface MockupState {\n id: number\n label: string\n sidebarActive: string\n previewTitle: string\n previewDescription: string\n previewCategory: string\n previewStatus: "draft" | "published" | "featured"\n previewUrl?: string\n}\n\nconst mockupStates: MockupState[] = [\n {\n id: 1,\n label: "Setup & styling",\n sidebarActive: "templates",\n previewTitle: "Atlas Directory",\n previewDescription: "Apply your brand, typography, and layout in minutes.",\n previewCategory: "Design systems",\n previewStatus: "draft",\n previewUrl: "atlas.directory/home",\n },\n {\n id: 2,\n label: "Plans & pricing",\n sidebarActive: "billing",\n previewTitle: "Pro Listing Plan",\n previewDescription: "Recurring billing, featured placements, and add-ons configured.",\n previewCategory: "Monetization",\n previewStatus: "draft",\n previewUrl: "atlas.directory/billing",\n },\n {\n id: 3,\n label: "SEO & publishing",\n sidebarActive: "seo",\n previewTitle: "Atlas Directory",\n previewDescription: "Schema, sitemap, and custom domain are ready to publish.",\n previewCategory: "SEO & domains",\n previewStatus: "published",\n previewUrl: "atlas.directory/launch",\n },\n {\n id: 4,\n label: "Payouts live",\n sidebarActive: "overview",\n previewTitle: "Atlas Directory",\n previewDescription: "Subscribers active, payouts scheduled to Stripe, featured slots sold.",\n previewCategory: "Revenue",\n previewStatus: "featured",\n previewUrl: "atlas.directory/analytics",\n },\n]\n\nexport const AnimatedMockup: React.FC = () => {\n const [currentState, setCurrentState] = useState(0)\n const [isPaused, setIsPaused] = useState(false)\n\n useEffect(() => {\n if (isPaused) return\n\n const interval = setInterval(() => {\n setCurrentState((prev) => (prev + 1) % mockupStates.length)\n }, 3000)\n\n return () => clearInterval(interval)\n }, [isPaused])\n\n const state = mockupStates[currentState]\n\n return (\n <div\n className="mockup-wrapper"\n onMouseEnter={() => setIsPaused(true)}\n onMouseLeave={() => setIsPaused(false)}\n >\n {/* Browser Chrome */}\n <div className="mockup-chrome">\n <div className="mockup-chrome-dots">\n <span className="dot dot-red" />\n <span className="dot dot-yellow" />\n <span className="dot dot-green" />\n </div>\n <div className="mockup-chrome-title">SaaSify</div>\n <div className="mockup-chrome-actions" />\n </div>\n\n {/* App Content */}\n <div className="mockup-content">\n {/* Sidebar */}\n <div className="mockup-sidebar">\n <div className="sidebar-header">\n <div className="sidebar-logo">\n <span className="logo-icon">D</span>\n <span className="logo-text">My Directory</span>\n </div>\n </div>\n <nav className="sidebar-nav">\n <SidebarItem icon="\u{1F4CA}" label="Overview" active={state.sidebarActive === "overview"} />\n <SidebarItem icon="\u{1F5BC}\uFE0F" label="Templates" active={state.sidebarActive === "templates"} />\n <SidebarItem\n icon="\u{1F4CB}"\n label="Listings"\n active={state.sidebarActive === "listings"}\n badge={24}\n />\n <SidebarItem\n icon="\u{1F4B3}"\n label="Plans & Billing"\n active={state.sidebarActive === "billing"}\n />\n <SidebarItem\n icon="\u26A1"\n label="Automations"\n active={state.sidebarActive === "automations"}\n />\n <SidebarItem icon="\u{1F50E}" label="SEO" active={state.sidebarActive === "seo"} />\n <SidebarItem icon="\u2699\uFE0F" label="Settings" active={state.sidebarActive === "settings"} />\n </nav>\n </div>\n\n {/* Main Content Area */}\n <div className="mockup-main">\n {/* Header */}\n <div className="main-header">\n <div className="header-title">\n <h2>{state.label}</h2>\n <span className="header-breadcrumb">SaaSify / {state.previewTitle}</span>\n </div>\n <div className="header-actions">\n <button\n type="button"\n className={cn(\n "action-btn",\n state.previewStatus === "published" && "action-btn--success",\n state.previewStatus === "featured" && "action-btn--featured",\n )}\n >\n {state.previewStatus === "draft" && "Save Draft"}\n {state.previewStatus === "published" && "\u2713 Published"}\n {state.previewStatus === "featured" && "\u2B50 Featured"}\n </button>\n </div>\n </div>\n\n {/* Split View: Editor + Preview */}\n <div className="main-split">\n {/* Editor Panel */}\n <div className="editor-panel">\n <div className="editor-section">\n <span className="editor-label">Listing Name</span>\n <div className="editor-input">\n <span className="input-text">{state.previewTitle}</span>\n <span className="input-cursor" />\n </div>\n </div>\n <div className="editor-section">\n <span className="editor-label">Category</span>\n <div className="editor-select">\n <span>{state.previewCategory}</span>\n <span className="select-arrow">\u25BC</span>\n </div>\n </div>\n <div className="editor-section">\n <span className="editor-label">Description</span>\n <div className="editor-textarea">\n <span className={cn("textarea-text", state.id >= 2 && "textarea-text--complete")}>\n {state.previewDescription}\n </span>\n </div>\n </div>\n </div>\n\n {/* Preview Panel */}\n <div className="preview-panel">\n <div className="preview-header">\n <span className="preview-label">Live Preview</span>\n <span className="preview-url">{state.previewUrl ?? "saasify.app/live"}</span>\n </div>\n <div className="preview-card">\n {state.previewStatus === "featured" && (\n <div className="preview-badge">\u2B50 Featured</div>\n )}\n <div className="preview-image">\n <div className="preview-image-placeholder">\n <span>\u{1F3E2}</span>\n </div>\n </div>\n <div className="preview-content">\n <span className="preview-category-tag">{state.previewCategory}</span>\n <h3 className="preview-title">{state.previewTitle}</h3>\n <p className="preview-description">{state.previewDescription}</p>\n <div className="preview-meta">\n <span className="meta-rating">\u2605\u2605\u2605\u2605\u2605</span>\n <span className="meta-reviews">24 reviews</span>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n {/* State Indicator */}\n <div className="mockup-indicators">\n {mockupStates.map((s, i) => (\n <button\n type="button"\n key={s.id}\n onClick={() => setCurrentState(i)}\n className={cn("indicator", i === currentState && "indicator--active")}\n >\n <span className="indicator-dot" />\n <span className="indicator-label">{s.label}</span>\n </button>\n ))}\n </div>\n </div>\n )\n}\n\ninterface SidebarItemProps {\n icon: string\n label: string\n active?: boolean\n badge?: number\n}\n\nconst SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, active, badge }) => (\n <div className={cn("sidebar-item", active && "sidebar-item--active")}>\n <span className="sidebar-icon">{icon}</span>\n <span className="sidebar-label">{label}</span>\n {badge && <span className="sidebar-badge">{badge}</span>}\n </div>\n)\n',
|
|
10171
9551
|
"marketing/payload/src/heros/ProductShowcase/index.tsx": '"use client"\n\nimport { useHeaderTheme } from "@/providers/HeaderTheme"\nimport Image from "next/image"\nimport type React from "react"\nimport { useEffect } from "react"\n\nimport type { Page } from "@/payload-types"\n\nimport { CTATracker } from "@/components/Analytics"\nimport { CMSLink } from "@/components/Link"\nimport { Media } from "@/components/Media"\nimport RichText from "@/components/RichText"\nimport { AnimatedMockup } from "./AnimatedMockup"\n\nexport const ProductShowcaseHero: React.FC<Page["hero"]> = ({\n links,\n richText,\n media,\n backgroundMedia,\n}) => {\n const { setHeaderTheme } = useHeaderTheme()\n const hasMedia = media && typeof media === "object"\n const hasBackgroundMedia = backgroundMedia && typeof backgroundMedia === "object"\n\n useEffect(() => {\n setHeaderTheme("light")\n }, [setHeaderTheme])\n\n return (\n <div className="relative overflow-hidden">\n {/* Hero Content - Left Aligned */}\n <div className="container mx-auto px-4 pt-8 pb-16 md:pt-16 md:pb-24">\n <div className="max-w-2xl">\n {richText && (\n <RichText\n className="mb-8 hero-content hero-content--left"\n data={richText}\n enableGutter={false}\n enableProse={false}\n />\n )}\n {Array.isArray(links) && links.length > 0 && (\n <ul className="flex flex-wrap gap-4">\n {links.map(({ link }, i) => {\n return (\n // biome-ignore lint/suspicious/noArrayIndexKey: Links are static and don\'t reorder\n <li key={i}>\n <CTATracker\n location="hero_product_showcase"\n variant={link?.label || `cta_${i}`}\n >\n <CMSLink {...link} size="lg" />\n </CTATracker>\n </li>\n )\n })}\n </ul>\n )}\n </div>\n </div>\n\n {/* Product Mockup Section (Cursor-style) */}\n <div className="container mx-auto px-4">\n <div className="hero-showcase">\n {/* Background Image - LCP element, needs fetchPriority="high" */}\n <div className="hero-bg-image">\n {hasBackgroundMedia ? (\n <Media\n resource={backgroundMedia}\n fill\n imgClassName="object-cover"\n priority\n size="100vw"\n />\n ) : (\n <Image\n src="/media/hero-bg.png"\n alt=""\n fill\n sizes="(max-width: 1280px) 100vw, 1280px"\n className="object-cover"\n priority\n fetchPriority="high"\n quality={75}\n />\n )}\n </div>\n\n {/* Mockup - centered within background */}\n <div className="hero-mockup-centered">\n {hasMedia ? (\n <div className="mockup-wrapper">\n <Media\n resource={media}\n imgClassName="w-full h-auto object-contain"\n size="(max-width: 768px) 100vw, (max-width: 1024px) 80vw, 960px"\n />\n </div>\n ) : (\n <AnimatedMockup />\n )}\n </div>\n </div>\n </div>\n </div>\n )\n}\n',
|
|
10172
9552
|
"marketing/payload/src/heros/RenderHero.tsx": 'import type React from "react"\n\nimport type { Page } from "@/payload-types"\n\nimport { HighImpactHero } from "@/heros/HighImpact"\nimport { LowImpactHero } from "@/heros/LowImpact"\nimport { MediumImpactHero } from "@/heros/MediumImpact"\nimport { ProductShowcaseHero } from "@/heros/ProductShowcase"\n\nconst heroes = {\n highImpact: HighImpactHero,\n lowImpact: LowImpactHero,\n mediumImpact: MediumImpactHero,\n productShowcase: ProductShowcaseHero,\n}\n\nexport const RenderHero: React.FC<Page["hero"]> = (props) => {\n const { type } = props || {}\n\n if (!type || type === "none") return null\n\n const HeroToRender = heroes[type]\n\n if (!HeroToRender) return null\n\n return <HeroToRender {...props} />\n}\n',
|
|
10173
9553
|
"marketing/payload/src/heros/config.ts": 'import type { Field } from "payload"\n\nimport {\n AlignFeature,\n BlockquoteFeature,\n ChecklistFeature,\n EXPERIMENTAL_TableFeature,\n FixedToolbarFeature,\n HeadingFeature,\n IndentFeature,\n InlineCodeFeature,\n InlineToolbarFeature,\n OrderedListFeature,\n RelationshipFeature,\n StrikethroughFeature,\n SubscriptFeature,\n SuperscriptFeature,\n UnorderedListFeature,\n UploadFeature,\n lexicalEditor,\n} from "@payloadcms/richtext-lexical"\n\nimport { linkGroup } from "@/fields/linkGroup"\n\nexport const hero: Field = {\n name: "hero",\n type: "group",\n fields: [\n {\n name: "type",\n type: "select",\n defaultValue: "lowImpact",\n label: "Type",\n options: [\n {\n label: "None",\n value: "none",\n },\n {\n label: "Product Showcase",\n value: "productShowcase",\n },\n {\n label: "High Impact",\n value: "highImpact",\n },\n {\n label: "Medium Impact",\n value: "mediumImpact",\n },\n {\n label: "Low Impact",\n value: "lowImpact",\n },\n ],\n required: true,\n },\n {\n name: "richText",\n type: "richText",\n editor: lexicalEditor({\n features: ({ rootFeatures }) => {\n return [\n ...rootFeatures,\n HeadingFeature({ enabledHeadingSizes: ["h1", "h2", "h3", "h4"] }),\n FixedToolbarFeature(),\n InlineToolbarFeature(),\n StrikethroughFeature(),\n SubscriptFeature(),\n SuperscriptFeature(),\n InlineCodeFeature(),\n BlockquoteFeature(),\n UnorderedListFeature(),\n OrderedListFeature(),\n ChecklistFeature(),\n AlignFeature(),\n IndentFeature(),\n RelationshipFeature(),\n UploadFeature(),\n EXPERIMENTAL_TableFeature(),\n ]\n },\n }),\n label: false,\n },\n linkGroup({\n overrides: {\n maxRows: 2,\n },\n }),\n {\n name: "media",\n type: "upload",\n admin: {\n condition: (_, { type } = {}) =>\n ["highImpact", "mediumImpact", "productShowcase"].includes(type),\n },\n relationTo: "media",\n required: false,\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n validate: (value: any, { siblingData }: { siblingData?: { type?: string } }) => {\n if (["highImpact", "mediumImpact"].includes(siblingData?.type ?? "") && !value) {\n return "Media is required for high impact and medium impact hero types"\n }\n return true\n },\n },\n {\n name: "backgroundMedia",\n type: "upload",\n admin: {\n condition: (_, { type } = {}) => type === "productShowcase",\n description: "Optional background illustration. Falls back to default if not set.",\n },\n relationTo: "media",\n required: false,\n label: "Background Image",\n },\n ],\n label: false,\n}\n',
|
|
@@ -10300,7 +9680,7 @@ export const useTheme = (): ThemeContextType => use(ThemeContext)
|
|
|
10300
9680
|
"marketing/payload/src/utilities/extractHeadings.ts": 'import type { DefaultTypedEditorState } from "@payloadcms/richtext-lexical"\n\nexport interface HeadingItem {\n id: string\n text: string\n depth: number\n}\n\n/**\n * Slugify a string for use as an anchor ID\n */\nexport function slugify(text: string): string {\n return text\n .toLowerCase()\n .replace(/[^a-z0-9\\s-]/g, "")\n .replace(/\\s+/g, "-")\n .replace(/-+/g, "-")\n .trim()\n}\n\n/**\n * Extract text content from a Lexical node recursively\n */\nfunction extractTextFromNode(node: Record<string, unknown>): string {\n if (node.type === "text" && typeof node.text === "string") {\n return node.text\n }\n\n if (Array.isArray(node.children)) {\n return node.children\n .map((child) => extractTextFromNode(child as Record<string, unknown>))\n .join("")\n }\n\n return ""\n}\n\n/**\n * Get heading depth from tag name\n */\nfunction getHeadingDepth(tag: string): number {\n const depthMap: Record<string, number> = {\n h1: 1,\n h2: 2,\n h3: 3,\n h4: 4,\n h5: 5,\n h6: 6,\n }\n return depthMap[tag] || 2\n}\n\n/**\n * Extract headings from Lexical editor state for table of contents\n */\nexport function extractHeadingsFromLexical(content: DefaultTypedEditorState): HeadingItem[] {\n const headings: HeadingItem[] = []\n\n if (!content?.root?.children) {\n return headings\n }\n\n for (const node of content.root.children) {\n const nodeRecord = node as Record<string, unknown>\n if (nodeRecord.type === "heading" && typeof nodeRecord.tag === "string") {\n const text = extractTextFromNode(nodeRecord)\n if (text.trim()) {\n headings.push({\n id: slugify(text),\n text: text.trim(),\n depth: getHeadingDepth(nodeRecord.tag),\n })\n }\n }\n }\n\n return headings\n}\n',
|
|
10301
9681
|
"marketing/payload/src/utilities/formatAuthors.ts": 'import type { Post } from "@/payload-types"\n\n/**\n * Formats an array of populatedAuthors from Posts into a prettified string.\n * @param authors - The populatedAuthors array from a Post.\n * @returns A prettified string of authors.\n * @example\n *\n * [Author1, Author2] becomes \'Author1 and Author2\'\n * [Author1, Author2, Author3] becomes \'Author1, Author2, and Author3\'\n *\n */\nexport const formatAuthors = (\n authors: NonNullable<NonNullable<Post["populatedAuthors"]>[number]>[],\n) => {\n // Ensure we don\'t have any authors without a name\n const authorNames = authors.map((author) => author.name).filter(Boolean)\n\n if (authorNames.length === 0) return ""\n if (authorNames.length === 1) return authorNames[0]\n if (authorNames.length === 2) return `${authorNames[0]} and ${authorNames[1]}`\n\n return `${authorNames.slice(0, -1).join(", ")} and ${authorNames[authorNames.length - 1]}`\n}\n',
|
|
10302
9682
|
"marketing/payload/src/utilities/formatDateTime.ts": "export const formatDateTime = (timestamp: string): string => {\n const now = new Date()\n let date = now\n if (timestamp) date = new Date(timestamp)\n const months = date.getMonth()\n const days = date.getDate()\n // const hours = date.getHours();\n // const minutes = date.getMinutes();\n // const seconds = date.getSeconds();\n\n const MM = months + 1 < 10 ? `0${months + 1}` : months + 1\n const DD = days < 10 ? `0${days}` : days\n const YYYY = date.getFullYear()\n // const AMPM = hours < 12 ? 'AM' : 'PM';\n // const HH = hours > 12 ? hours - 12 : hours;\n // const MinMin = (minutes < 10) ? `0${minutes}` : minutes;\n // const SS = (seconds < 10) ? `0${seconds}` : seconds;\n\n return `${MM}/${DD}/${YYYY}`\n}\n",
|
|
10303
|
-
"marketing/payload/src/utilities/generateMeta.ts": 'import type { Metadata } from "next"\n\nimport type { Config, Media, Page, Post } from "../payload-types"\n\nimport { getServerSideURL } from "./getURL"\nimport { mergeOpenGraph } from "./mergeOpenGraph"\n\nconst getImageURL = (image?: Media | Config["db"]["defaultIDType"] | null) => {\n const serverUrl = getServerSideURL()\n\n let url = `${serverUrl}/website-template-OG.webp`\n\n if (image && typeof image === "object" && "url" in image) {\n const ogUrl = image.sizes?.og?.url\n\n url = ogUrl ? `${serverUrl}${ogUrl}` : `${serverUrl}${image.url}`\n }\n\n return url\n}\n\n// Default keywords for SEO\nconst defaultKeywords = [\n "
|
|
9683
|
+
"marketing/payload/src/utilities/generateMeta.ts": 'import type { Metadata } from "next"\n\nimport type { Config, Media, Page, Post } from "../payload-types"\n\nimport { getServerSideURL } from "./getURL"\nimport { mergeOpenGraph } from "./mergeOpenGraph"\n\nconst getImageURL = (image?: Media | Config["db"]["defaultIDType"] | null) => {\n const serverUrl = getServerSideURL()\n\n let url = `${serverUrl}/website-template-OG.webp`\n\n if (image && typeof image === "object" && "url" in image) {\n const ogUrl = image.sizes?.og?.url\n\n url = ogUrl ? `${serverUrl}${ogUrl}` : `${serverUrl}${image.url}`\n }\n\n return url\n}\n\n// Default keywords for SEO\nconst defaultKeywords = [\n "SaaS platform",\n "team productivity",\n "workflow automation",\n "business software",\n "collaboration tools",\n "project management",\n "team collaboration",\n "business automation",\n "startup tools",\n "productivity software",\n]\n\nexport const generateMeta = async (args: {\n doc: Partial<Page> | Partial<Post> | null\n}): Promise<Metadata> => {\n const { doc } = args\n const serverUrl = getServerSideURL()\n\n const ogImage = getImageURL(doc?.meta?.image)\n\n const title = doc?.meta?.title\n ? `${doc?.meta?.title} | SaaSify`\n : "SaaSify - The Modern Platform for Growing Teams"\n\n const description =\n doc?.meta?.description ||\n "Streamline workflows, boost productivity, and scale your business with one powerful platform. The modern solution for teams that want to work smarter."\n\n // Generate canonical URL\n const slug = Array.isArray(doc?.slug) ? doc?.slug.join("/") : doc?.slug || ""\n const canonicalUrl = slug === "home" ? serverUrl : `${serverUrl}/${slug}`\n\n return {\n title,\n description,\n keywords: defaultKeywords,\n authors: [{ name: "SaaSify", url: serverUrl }],\n creator: "SaaSify",\n publisher: "SaaSify",\n robots: {\n index: true,\n follow: true,\n googleBot: {\n index: true,\n follow: true,\n "max-video-preview": -1,\n "max-image-preview": "large",\n "max-snippet": -1,\n },\n },\n alternates: {\n canonical: canonicalUrl,\n },\n openGraph: mergeOpenGraph({\n description,\n images: ogImage\n ? [\n {\n url: ogImage,\n width: 1200,\n height: 630,\n alt: title,\n },\n ]\n : undefined,\n title,\n url: canonicalUrl,\n }),\n }\n}\n',
|
|
10304
9684
|
"marketing/payload/src/utilities/generatePreviewPath.ts": 'import type { CollectionSlug, PayloadRequest } from "payload"\n\nconst collectionPrefixMap: Partial<Record<CollectionSlug, string>> = {\n posts: "/posts",\n pages: "",\n}\n\ntype Props = {\n collection: keyof typeof collectionPrefixMap\n slug: string\n req: PayloadRequest\n}\n\nexport const generatePreviewPath = ({ collection, slug }: Props) => {\n // Allow empty strings, e.g. for the homepage\n if (slug === undefined || slug === null) {\n return null\n }\n\n // Encode to support slugs with special characters\n const encodedSlug = encodeURIComponent(slug)\n\n const encodedParams = new URLSearchParams({\n slug: encodedSlug,\n collection,\n path: `${collectionPrefixMap[collection]}/${encodedSlug}`,\n previewSecret: process.env.PREVIEW_SECRET || "",\n })\n\n const url = `/next/preview?${encodedParams.toString()}`\n\n return url\n}\n',
|
|
10305
9685
|
"marketing/payload/src/utilities/getDocument.ts": 'import type { Config } from "src/payload-types"\n\nimport configPromise from "@payload-config"\nimport { unstable_cache } from "next/cache"\nimport { getPayload } from "payload"\n\ntype Collection = keyof Config["collections"]\n\nasync function getDocument(collection: Collection, slug: string, depth = 2) {\n const payload = await getPayload({ config: configPromise })\n\n const page = await payload.find({\n collection,\n depth,\n where: {\n slug: {\n equals: slug,\n },\n },\n })\n\n return page.docs[0]\n}\n\n/**\n * Returns a unstable_cache function mapped with the cache tag for the slug\n * @param depth - Depth for populating relationships (default: 2 for rich text content)\n */\nexport const getCachedDocument = (collection: Collection, slug: string, depth = 2) =>\n unstable_cache(async () => getDocument(collection, slug, depth), [collection, slug], {\n tags: [`${collection}_${slug}`],\n })\n',
|
|
10306
9686
|
"marketing/payload/src/utilities/getGlobals.ts": 'import type { Config } from "src/payload-types"\n\nimport configPromise from "@payload-config"\nimport { unstable_cache } from "next/cache"\nimport { getPayload } from "payload"\n\ntype Global = keyof Config["globals"]\n\nasync function getGlobal(slug: Global, depth = 0) {\n try {\n const payload = await getPayload({ config: configPromise })\n\n const global = await payload.findGlobal({\n slug,\n depth,\n })\n\n return global\n } catch (error) {\n // Database tables may not exist yet on first launch\n // Return null so components can show a setup UI instead of crashing\n console.warn(`Could not fetch global "${String(slug)}". Database may not be initialized yet.`)\n return null\n }\n}\n\n/**\n * Returns a unstable_cache function mapped with the cache tag for the slug\n */\nexport const getCachedGlobal = (slug: Global, depth = 0) =>\n unstable_cache(async () => getGlobal(slug, depth), [slug], {\n tags: [`global_${slug}`],\n })\n',
|
|
@@ -10334,7 +9714,7 @@ export const getCachedRedirects = () =>
|
|
|
10334
9714
|
})
|
|
10335
9715
|
`,
|
|
10336
9716
|
"marketing/payload/src/utilities/getURL.ts": 'import canUseDOM from "./canUseDOM"\n\nexport const getServerSideURL = () => {\n return (\n process.env.NEXT_PUBLIC_SERVER_URL ||\n (process.env.VERCEL_PROJECT_PRODUCTION_URL\n ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`\n : "http://localhost:3000")\n )\n}\n\nexport const getClientSideURL = () => {\n if (canUseDOM) {\n const protocol = window.location.protocol\n const domain = window.location.hostname\n const port = window.location.port\n\n return `${protocol}//${domain}${port ? `:${port}` : ""}`\n }\n\n if (process.env.VERCEL_PROJECT_PRODUCTION_URL) {\n return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`\n }\n\n return process.env.NEXT_PUBLIC_SERVER_URL || ""\n}\n',
|
|
10337
|
-
"marketing/payload/src/utilities/mergeOpenGraph.ts": 'import type { Metadata } from "next"\nimport { getServerSideURL } from "./getURL"\n\nconst defaultOpenGraph: Metadata["openGraph"] = {\n type: "website",\n description:\n "
|
|
9717
|
+
"marketing/payload/src/utilities/mergeOpenGraph.ts": 'import type { Metadata } from "next"\nimport { getServerSideURL } from "./getURL"\n\nconst defaultOpenGraph: Metadata["openGraph"] = {\n type: "website",\n description:\n "Streamline workflows, boost productivity, and scale your business with one powerful platform.",\n images: [\n {\n url: `${getServerSideURL()}/website-template-OG.webp`,\n width: 1200,\n height: 630,\n alt: "SaaSify - The Modern Platform for Growing Teams",\n },\n ],\n siteName: "SaaSify",\n title: "SaaSify - The Modern Platform for Growing Teams",\n}\n\nexport const mergeOpenGraph = (og?: Metadata["openGraph"]): Metadata["openGraph"] => {\n return {\n ...defaultOpenGraph,\n ...og,\n images: og?.images ? og.images : defaultOpenGraph.images,\n }\n}\n',
|
|
10338
9718
|
"marketing/payload/src/utilities/toKebabCase.ts": 'export const toKebabCase = (string: string): string =>\n string\n ?.replace(/([a-z])([A-Z])/g, "$1-$2")\n .replace(/\\s+/g, "-")\n .toLowerCase()\n',
|
|
10339
9719
|
"marketing/payload/src/utilities/ui.ts": '/**\n * Utility functions for UI components automatically added by ShadCN and used in a few of our frontend components and blocks.\n *\n * Other functions may be exported from here in the future or by installing other shadcn components.\n */\n\nimport { type ClassValue, clsx } from "clsx"\nimport { twMerge } from "tailwind-merge"\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs))\n}\n',
|
|
10340
9720
|
"marketing/payload/src/utilities/useClickableCard.ts": '"use client"\nimport type { RefObject } from "react"\n\nimport { useRouter } from "next/navigation"\nimport { useCallback, useEffect, useRef } from "react"\n\ntype UseClickableCardType<T extends HTMLElement> = {\n card: {\n ref: RefObject<T | null>\n }\n link: {\n ref: RefObject<HTMLAnchorElement | null>\n }\n}\n\ninterface Props {\n external?: boolean\n newTab?: boolean\n scroll?: boolean\n}\n\nfunction useClickableCard<T extends HTMLElement>({\n external = false,\n newTab = false,\n scroll = true,\n}: Props): UseClickableCardType<T> {\n const router = useRouter()\n const card = useRef<T>(null)\n const link = useRef<HTMLAnchorElement>(null)\n const timeDown = useRef<number>(0)\n const hasActiveParent = useRef<boolean>(false)\n const pressedButton = useRef<number>(0)\n\n const handleMouseDown = useCallback(\n (e: MouseEvent) => {\n if (e.target) {\n const target = e.target as Element\n\n const timeNow = +new Date()\n const parent = target?.closest("a")\n\n pressedButton.current = e.button\n\n if (!parent) {\n hasActiveParent.current = false\n timeDown.current = timeNow\n } else {\n hasActiveParent.current = true\n }\n }\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [router, card, link, timeDown],\n )\n\n const handleMouseUp = useCallback(\n (e: MouseEvent) => {\n if (link.current?.href) {\n const timeNow = +new Date()\n const difference = timeNow - timeDown.current\n\n if (link.current?.href && difference <= 250) {\n if (!hasActiveParent.current && pressedButton.current === 0 && !e.ctrlKey) {\n if (external) {\n const target = newTab ? "_blank" : "_self"\n window.open(link.current.href, target)\n } else {\n router.push(link.current.href, { scroll })\n }\n }\n }\n }\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [router, card, link, timeDown],\n )\n\n useEffect(() => {\n const cardNode = card.current\n\n const abortController = new AbortController()\n\n if (cardNode) {\n cardNode.addEventListener("mousedown", handleMouseDown, {\n signal: abortController.signal,\n })\n cardNode.addEventListener("mouseup", handleMouseUp, {\n signal: abortController.signal,\n })\n }\n\n return () => {\n abortController.abort()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [card, link, router])\n\n return {\n card: {\n ref: card,\n },\n link: {\n ref: link,\n },\n }\n}\n\nexport default useClickableCard\n',
|