├── .nvmrc ├── .eslintignore ├── src ├── utils │ ├── constants.ts │ └── dates.ts ├── app │ ├── _sections │ │ ├── pricing │ │ │ ├── pricing.module.scss │ │ │ └── index.tsx │ │ ├── companies │ │ │ ├── companies.module.scss │ │ │ └── index.tsx │ │ ├── features │ │ │ ├── hero │ │ │ │ ├── hero.module.scss │ │ │ │ └── index.tsx │ │ │ ├── big-feature │ │ │ │ └── index.tsx │ │ │ ├── features-grid │ │ │ │ └── index.tsx │ │ │ ├── features-list │ │ │ │ └── index.tsx │ │ │ └── side-features │ │ │ │ └── index.tsx │ │ ├── freeform-text │ │ │ └── index.tsx │ │ ├── accordion-faq │ │ │ ├── index.tsx │ │ │ └── accordion.tsx │ │ ├── callout-1 │ │ │ ├── callout-1.module.scss │ │ │ └── index.tsx │ │ ├── testimonials-grid │ │ │ ├── index.tsx │ │ │ └── testimonials-list.tsx │ │ ├── testimonials │ │ │ ├── index.tsx │ │ │ └── slider.tsx │ │ ├── pricing-comparation │ │ │ ├── fragments.ts │ │ │ ├── mobile-pricing-comparison.tsx │ │ │ └── index.tsx │ │ ├── faq │ │ │ └── index.tsx │ │ ├── callout-2 │ │ │ └── index.tsx │ │ ├── newsletter │ │ │ └── index.tsx │ │ ├── form │ │ │ └── index.tsx │ │ └── hero │ │ │ └── index.tsx │ ├── _components │ │ ├── rich-text │ │ │ ├── rich-text.module.scss │ │ │ └── index.tsx │ │ ├── page-view │ │ │ └── index.tsx │ │ ├── code-snippet │ │ │ ├── copy-button.tsx │ │ │ ├── language.tsx │ │ │ ├── index.tsx │ │ │ └── code-snippet.module.scss │ │ ├── select.tsx │ │ ├── tracked_button │ │ │ └── index.tsx │ │ ├── labeled-input.tsx │ │ ├── theme-switcher.tsx │ │ ├── form-layout.tsx │ │ ├── header │ │ │ └── index.tsx │ │ └── footer │ │ │ └── index.tsx │ ├── changelog │ │ ├── _components │ │ │ ├── changelog.fragment.ts │ │ │ ├── changelog-header.tsx │ │ │ └── changelog-list.tsx │ │ ├── rss.xml │ │ │ └── route.ts │ │ └── page.tsx │ ├── providers.tsx │ ├── not-found.tsx │ ├── blog │ │ ├── rss.xml │ │ │ └── route.ts │ │ ├── page.tsx │ │ ├── _components │ │ │ └── blogpost-card.tsx │ │ └── [slug] │ │ │ └── page.tsx │ ├── globals.css │ ├── sitemap.ts │ ├── layout.tsx │ └── [[...slug]] │ │ └── page.tsx ├── lib │ ├── constants │ │ └── index.ts │ └── basehub │ │ ├── utils.ts │ │ └── fragments.ts ├── hooks │ ├── use-has-rendered.ts │ └── use-toggle-state.ts ├── common │ ├── layout.tsx │ ├── avatars-group.tsx │ ├── avatar.tsx │ ├── input.tsx │ ├── tooltip.tsx │ ├── dark-light-image.tsx │ ├── heading.tsx │ ├── button.tsx │ └── search │ │ └── index.tsx └── context │ ├── search-hits-context.tsx │ └── basehub-theme-provider.tsx ├── .env.example ├── .eslintrc.json ├── basehub.config.ts ├── postcss.config.js ├── .editorconfig ├── public ├── robots.txt ├── sitemap.xml └── sitemap-0.xml ├── .prettierrc ├── next.config.ts ├── .vscode ├── settings.json └── launch.json ├── .gitignore ├── tsconfig.json ├── package.json ├── README.md └── tailwind.config.ts /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.x -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .basehub/ 3 | public/ 4 | .vscode/ 5 | tsconfig.json -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const isDev = process.env.NODE_ENV === "development"; 2 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # BaseHub token retrieved here: https://basehub.com 2 | BASEHUB_TOKEN="" 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /basehub.config.ts: -------------------------------------------------------------------------------- 1 | import { setGlobalConfig } from "basehub"; 2 | 3 | setGlobalConfig({}); 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /src/app/_sections/pricing/pricing.module.scss: -------------------------------------------------------------------------------- 1 | .pricing-card { 2 | box-shadow: -100px 50px 200px 0px rgba(255, 255, 255, 0.02) inset; 3 | } -------------------------------------------------------------------------------- /src/app/_sections/companies/companies.module.scss: -------------------------------------------------------------------------------- 1 | .scrollbar { 2 | /* width */ 3 | &::-webkit-scrollbar { 4 | display: none; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/app/_sections/features/hero/hero.module.scss: -------------------------------------------------------------------------------- 1 | .gradient { 2 | background: radial-gradient(closest-side, rgba(var(--accent-rgb-400) / 0.4) 0%, transparent 100%); 3 | z-index: 1; 4 | } -------------------------------------------------------------------------------- /src/lib/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const siteHost = 2 | process.env.VERCEL_PROJECT_PRODUCTION_URL || "nextjs-marketing-website.basehub.com"; 3 | export const siteUrl = `https://${siteHost}`; -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | max_line_length = 80 10 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # * 2 | User-agent: * 3 | Allow: / 4 | 5 | # Host 6 | Host: https://nextjs-marketing-website.basehub.com 7 | 8 | # Sitemaps 9 | Sitemap: https://nextjs-marketing-website.basehub.com/sitemap.xml 10 | -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | https://nextjs-marketing-website.basehub.com/sitemap-0.xml 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 100, 4 | "trailingComma": "all", 5 | "semi": true, 6 | "singleQuote": false, 7 | "bracketSpacing": true, 8 | "arrowParens": "always", 9 | "endOfLine": "auto", 10 | "plugins": ["prettier-plugin-tailwindcss"] 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/use-has-rendered.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export const useHasRendered = () => { 4 | const [hasRendered, setHasRendered] = React.useState(false); 5 | 6 | React.useEffect(() => { 7 | setHasRendered(true); 8 | }, []); 9 | 10 | return hasRendered; 11 | }; 12 | -------------------------------------------------------------------------------- /src/app/_components/rich-text/rich-text.module.scss: -------------------------------------------------------------------------------- 1 | .rich-text { 2 | li { 3 | p:first-child { 4 | margin-top: 0; 5 | margin-bottom: 8px !important; 6 | } 7 | p:first-child + p { 8 | margin-top: 0; 9 | } 10 | 11 | p:last-child { 12 | margin-bottom: 0; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import { NextConfig } from "next"; 2 | 3 | const nextConfig = { 4 | logging: { 5 | fetches: { 6 | fullUrl: true, 7 | }, 8 | }, 9 | images: { 10 | remotePatterns: [{ hostname: "assets.basehub.com" }, { hostname: "basehub.earth" }], 11 | }, 12 | } satisfies NextConfig; 13 | 14 | export default nextConfig; 15 | -------------------------------------------------------------------------------- /src/utils/dates.ts: -------------------------------------------------------------------------------- 1 | const defaultOptions: Intl.DateTimeFormatOptions = { 2 | year: "numeric", 3 | month: "long", 4 | day: "numeric", 5 | }; 6 | 7 | export const formatDate = (date: string | Date | number, options?: Intl.DateTimeFormatOptions) => { 8 | return new Date(date).toLocaleDateString("en-US", { 9 | ...defaultOptions, 10 | ...options, 11 | timeZone: "UTC", 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /src/hooks/use-toggle-state.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export const useToggleState = (initialState = false) => { 4 | const [isOn, setIsOn] = React.useState(initialState); 5 | 6 | const handleToggle = () => setIsOn((prev) => !prev); 7 | const handleOff = () => setIsOn(false); 8 | const handleOn = () => setIsOn(true); 9 | 10 | return { isOn, handleToggle, handleOff, handleOn }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/app/_components/page-view/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { GeneralEvents } from "@/../basehub-types"; 4 | import { sendEvent } from "basehub/events"; 5 | import * as React from "react"; 6 | 7 | export function PageView({ ingestKey }: { ingestKey: GeneralEvents["ingestKey"] }) { 8 | React.useEffect(() => { 9 | sendEvent(ingestKey, { 10 | eventType: "view", 11 | }); 12 | }, [ingestKey]); 13 | 14 | return null; 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/basehub/utils.ts: -------------------------------------------------------------------------------- 1 | export function getArticleSlugFromSlugPath(slugPath: string) { 2 | // article _slugPath will have something like root index categories-section categories articles children children ... 3 | // remove root/pages and then filter out every other part 4 | return ( 5 | "/" + 6 | slugPath 7 | .replace(/(root|site|posts)\s/gm, "") 8 | .split(/\s/) 9 | .join("/") 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/changelog/_components/changelog.fragment.ts: -------------------------------------------------------------------------------- 1 | import { fragmentOn } from "basehub"; 2 | import { authorFragment, optimizedImageFragment } from "@/lib/basehub/fragments"; 3 | 4 | export const changelogListFragment = fragmentOn("ChangelogPostComponent", { 5 | _id: true, 6 | _title: true, 7 | image: optimizedImageFragment, 8 | authors: authorFragment, 9 | excerpt: true, 10 | _slug: true, 11 | publishedAt: true, 12 | }); 13 | 14 | export type ChangelogListFragment = fragmentOn.infer; 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "typescript.tsdk": "node_modules\\typescript\\lib", 4 | "typescript.enablePromptUseWorkspaceTsdk": true, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": "always" 7 | }, 8 | "editor.defaultFormatter": "esbenp.prettier-vscode", 9 | "tailwindCSS.experimental.classRegex": [ 10 | ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], 11 | ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"], 12 | ["clsx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from "next-themes"; 2 | import { Toolbar } from "basehub/next-toolbar"; 3 | 4 | import { BaseHubThemeProvider } from "@/context/basehub-theme-provider"; 5 | import { TooltipProvider } from "@/common/tooltip"; 6 | 7 | export function Providers({ children }: { children: React.ReactNode }) { 8 | return ( 9 | 10 | 11 | 12 | {children} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/app/_components/code-snippet/copy-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCopyToClipboard } from "basehub/react-code-block/client"; 4 | import { Button } from "@/common/button"; 5 | import { ClipboardCopyIcon, CheckIcon } from "@radix-ui/react-icons"; 6 | 7 | export function CopyButton() { 8 | const { copied, onCopy } = useCopyToClipboard(); 9 | 10 | return ( 11 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # BaseHub 39 | .basehub 40 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonLink } from "@/common/button"; 2 | import { Heading } from "@/common/heading"; 3 | import { Section } from "@/common/layout"; 4 | 5 | export default function NotFound() { 6 | return ( 7 |
8 | 12 |

Page not found

13 |
14 | 15 | Go back to Homepage 16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/common/layout.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from "class-variance-authority"; 2 | 3 | export const $section = cva("py-14 md:py-[72px] flex flex-col items-center gap-10 relative", { 4 | variants: { 5 | container: { 6 | default: "container mx-auto px-6", 7 | full: "", 8 | }, 9 | }, 10 | defaultVariants: { 11 | container: "default", 12 | }, 13 | }); 14 | 15 | type SectionProps = React.AllHTMLAttributes & VariantProps; 16 | 17 | export function Section({ className, container, ...props }: SectionProps) { 18 | return
; 19 | } 20 | -------------------------------------------------------------------------------- /src/app/_sections/freeform-text/index.tsx: -------------------------------------------------------------------------------- 1 | import { fragmentOn } from "basehub"; 2 | import { RichText } from "basehub/react-rich-text"; 3 | import { Section } from "@/common/layout"; 4 | import { richTextClasses } from "@/app/_components/rich-text"; 5 | 6 | export const freeformTextFragment = fragmentOn("FreeformTextComponent", { 7 | body: { json: { content: true } }, 8 | }); 9 | 10 | export type FreeformText = fragmentOn.infer; 11 | 12 | export function FreeformText(freeformText: FreeformText) { 13 | return ( 14 |
15 |
16 | 17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/_sections/accordion-faq/index.tsx: -------------------------------------------------------------------------------- 1 | import type { Faq } from "../faq"; 2 | 3 | import { Heading } from "@/common/heading"; 4 | import { Section } from "@/common/layout"; 5 | 6 | import { Accordion } from "./accordion"; 7 | import { GeneralEvents } from "@/../basehub-types"; 8 | 9 | export function AccordionFaq(faq: Faq & { eventsKey: GeneralEvents["ingestKey"] }) { 10 | return ( 11 |
12 | 13 |

{faq.heading.title}

14 |
15 |
16 | 17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Next.js: debug server-side", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "pnpm dev" 9 | }, 10 | { 11 | "name": "Next.js: debug client-side", 12 | "type": "chrome", 13 | "request": "launch", 14 | "url": "http://localhost:3000" 15 | }, 16 | { 17 | "name": "Next.js: debug full stack", 18 | "type": "node-terminal", 19 | "request": "launch", 20 | "command": "pnpm dev", 21 | "serverReadyAction": { 22 | "pattern": "started server on .+, url: (https?://.+)", 23 | "uriFormat": "%s", 24 | "action": "debugWithChrome" 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/common/avatars-group.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import * as React from "react"; 3 | import { TooltipProvider } from "./tooltip"; 4 | 5 | export function AvatarsGroup({ 6 | className, 7 | children, 8 | animate = false, 9 | ...props 10 | }: React.HTMLAttributes & { animate?: boolean }) { 11 | if (animate) 12 | return ( 13 | 14 |
18 | {children} 19 |
20 |
21 | ); 22 | 23 | return ( 24 |
25 | {children} 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/app/_sections/callout-1/callout-1.module.scss: -------------------------------------------------------------------------------- 1 | @use "sass:math"; 2 | 3 | @keyframes lineAnimation { 4 | 0% { 5 | transform: translateX(-100%); 6 | } 7 | 100% { 8 | transform: translateX(100%); 9 | } 10 | } 11 | 12 | .line { 13 | animation: lineAnimation 3s linear infinite; 14 | transform-origin: right; 15 | height: 1px; 16 | transform: translateX(-100%); 17 | // Shadow circle on the right 18 | &::after { 19 | content: ""; 20 | position: absolute; 21 | right: 0; 22 | top: 50%; 23 | transform: translateY(-50%); 24 | width: 1px; 25 | height: 1px; 26 | border-radius: 50%; 27 | background: transparent; 28 | box-shadow: 0 0 30px rgba(255, 255, 255, 0.5); 29 | } 30 | } 31 | 32 | @for $i from 1 through 10 { 33 | .line:nth-child(#{$i}) { 34 | animation-delay: math.random() * 5s; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/_sections/testimonials-grid/index.tsx: -------------------------------------------------------------------------------- 1 | import { Heading } from "@/common/heading"; 2 | import { Section } from "@/common/layout"; 3 | import { fragmentOn } from "basehub"; 4 | import { headingFragment, quoteFragment } from "@/lib/basehub/fragments"; 5 | 6 | import { TestimonialsGridClient } from "./testimonials-list"; 7 | 8 | export const testimonialsGridFragment = fragmentOn("TestimonialsGridComponent", { 9 | heading: headingFragment, 10 | quotes: quoteFragment, 11 | }); 12 | 13 | type TestimonialsGrid = fragmentOn.infer; 14 | 15 | export function TestimonialsGrid({ heading, quotes }: TestimonialsGrid) { 16 | return ( 17 |
18 | 19 |

{heading.title}

20 |
21 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/context/search-hits-context.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import * as React from "react"; 3 | 4 | import { type AvatarFragment } from "@/lib/basehub/fragments"; 5 | 6 | interface SearchHitsContextType { 7 | authorsAvatars: Record; 8 | } 9 | 10 | const SearchHitsContext = React.createContext(undefined); 11 | 12 | export function SearchHitsProvider({ 13 | authorsAvatars: authors, 14 | children, 15 | }: React.PropsWithChildren) { 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | } 22 | 23 | export function useSearchHits() { 24 | const context = React.useContext(SearchHitsContext); 25 | 26 | if (!context) { 27 | throw new Error("useSearchHits must be used within a SearchHitsProvider"); 28 | } 29 | 30 | return context; 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "react-jsx", 19 | "incremental": true, 20 | "noUncheckedIndexedAccess": true, 21 | "plugins": [ 22 | { 23 | "name": "next" 24 | } 25 | ], 26 | "paths": { 27 | "@/*": [ 28 | "./src/*" 29 | ] 30 | } 31 | }, 32 | "include": [ 33 | "next-env.d.ts", 34 | "**/*.ts", 35 | "**/*.tsx", 36 | ".next/types/**/*.ts", 37 | "basehub.d.ts", 38 | "basehub.config.ts", 39 | "basehub-types.d.ts", 40 | ".next/dev/types/**/*.ts" 41 | ], 42 | "exclude": [ 43 | "node_modules" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /src/app/_sections/testimonials/index.tsx: -------------------------------------------------------------------------------- 1 | import { Section } from "@/common/layout"; 2 | import { Heading } from "@/common/heading"; 3 | import { fragmentOn } from "basehub"; 4 | import { headingFragment, quoteFragment } from "@/lib/basehub/fragments"; 5 | 6 | import { Slider } from "./slider"; 7 | 8 | export const testimonialsSliderFragment = fragmentOn("TestimonialSliderComponent", { 9 | heading: headingFragment, 10 | quotes: quoteFragment, 11 | }); 12 | 13 | export type TestimonialsSlider = fragmentOn.infer; 14 | 15 | export function Testimonials({ heading, quotes }: TestimonialsSlider) { 16 | return ( 17 |
18 |
19 | 20 | {heading.align === "none" ? ( 21 |
22 | ) : ( 23 | 24 |

{heading.title}

25 |
26 | )} 27 | 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/app/_sections/pricing-comparation/fragments.ts: -------------------------------------------------------------------------------- 1 | import { fragmentOn } from "basehub"; 2 | import { headingFragment } from "@/lib/basehub/fragments"; 3 | 4 | export const planFragment = fragmentOn("PricingPlanComponent", { 5 | _id: true, 6 | _title: true, 7 | price: true, 8 | isMostPopular: true, 9 | }); 10 | 11 | export const valueFragment = fragmentOn("ValueComponent", { 12 | _id: true, 13 | plan: planFragment, 14 | value: { 15 | __typename: true, 16 | on_BooleanComponent: { 17 | _id: true, 18 | boolean: true, 19 | }, 20 | on_CustomTextComponent: { 21 | _id: true, 22 | text: true, 23 | }, 24 | }, 25 | }); 26 | 27 | export const pricingTableFragment = fragmentOn("PricingTableComponent", { 28 | heading: headingFragment, 29 | categories: { 30 | items: { 31 | _id: true, 32 | _title: true, 33 | features: { 34 | items: { 35 | _id: true, 36 | _title: true, 37 | tooltip: true, 38 | values: { 39 | items: valueFragment, 40 | }, 41 | }, 42 | }, 43 | }, 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /src/app/_components/select.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { ChevronDownIcon } from "@radix-ui/react-icons"; 3 | import clsx from "clsx"; 4 | 5 | import * as React from "react"; 6 | 7 | export function Select({ 8 | children, 9 | className, 10 | ...props 11 | }: React.SelectHTMLAttributes) { 12 | const [value, setValue] = React.useState(""); 13 | 14 | return ( 15 |
16 | 31 | 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/app/_sections/faq/index.tsx: -------------------------------------------------------------------------------- 1 | import { Heading } from "@/common/heading"; 2 | import { Section } from "@/common/layout"; 3 | import { headingFragment } from "@/lib/basehub/fragments"; 4 | import { fragmentOn } from "basehub"; 5 | 6 | export const faqFragment = fragmentOn("FaqComponent", { 7 | heading: headingFragment, 8 | questions: { 9 | items: { 10 | _analyticsKey: true, 11 | _title: true, 12 | answer: true, 13 | }, 14 | }, 15 | }); 16 | 17 | export type Faq = fragmentOn.infer; 18 | 19 | export function Faq(faq: Faq) { 20 | return ( 21 |
22 | 23 |

{faq.heading.title}

24 |
25 |
    26 | {faq.questions.items.map((question) => ( 27 |
  • 28 |

    29 | {question._title} 30 |

    31 |

    32 | {question.answer} 33 |

    34 |
  • 35 | ))} 36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/common/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import clsx from "clsx"; 3 | import { BaseHubImage } from "basehub/next-image"; 4 | 5 | import { type AvatarFragment, type AuthorFragment } from "@/lib/basehub/fragments"; 6 | 7 | import { CustomTooltip } from "./tooltip"; 8 | import type { ImageProps } from "next/image"; 9 | 10 | export function Author({ 11 | image, 12 | _title, 13 | ...props 14 | }: AuthorFragment & Omit) { 15 | return ( 16 | 17 | 25 | 26 | ); 27 | } 28 | 29 | export function Avatar({ 30 | className, 31 | alt, 32 | url, 33 | ...props 34 | }: AvatarFragment & Omit) { 35 | return ( 36 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/app/_components/code-snippet/language.tsx: -------------------------------------------------------------------------------- 1 | import { type Language } from "basehub/react-code-block"; 2 | 3 | // Define PartialBundledLanguage as Partial of BundledLanguage 4 | type PartialBundledLanguage = Partial>; 5 | 6 | export const languagesIcons: PartialBundledLanguage = { 7 | javascript: ( 8 | 9 | 15 | 16 | ), 17 | }; 18 | -------------------------------------------------------------------------------- /src/app/blog/rss.xml/route.ts: -------------------------------------------------------------------------------- 1 | import { siteUrl } from "@/lib/constants"; 2 | import { basehub } from "basehub"; 3 | 4 | 5 | export async function GET() { 6 | const data = await basehub().query({ 7 | site: { 8 | blog: { 9 | mainTitle: true, 10 | posts: { 11 | __args: { 12 | orderBy: "publishedAt__DESC", 13 | }, 14 | items: { 15 | _title: true, 16 | _slug: true, 17 | description: true, 18 | publishedAt: true, 19 | }, 20 | }, 21 | }, 22 | }, 23 | }); 24 | 25 | const feed = ` 26 | 27 | 28 | ${data.site.blog.mainTitle} 29 | ${siteUrl}/blog 30 | en-us${data.site.blog.posts.items 31 | .map((post) => { 32 | return ` 33 | 34 | ${post._title} 35 | ${siteUrl}/blog/${post._slug} 36 | ${post.description} 37 | ${post.publishedAt} 38 | `; 39 | }) 40 | .join("")} 41 | 42 | `; 43 | 44 | return new Response(feed, { 45 | status: 200, 46 | headers: { "Content-Type": "application/rss+xml" }, 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /src/common/input.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | import { Button } from "./button"; 4 | 5 | export function Input({ 6 | className, 7 | disabled, 8 | error, 9 | buttonContent = "Submit", 10 | ...props 11 | }: React.ComponentProps<"input"> & { error?: string | null; buttonContent?: string }) { 12 | return ( 13 |
14 | 27 | {error ? ( 28 |

{error}

29 | ) : null} 30 | 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/app/_components/tracked_button/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { sendEvent } from "basehub/events"; 4 | import { Button, ButtonLink } from "@/common/button"; 5 | import { GeneralEvents } from "@/../basehub-types"; 6 | 7 | interface TrackProps { 8 | analyticsKey: GeneralEvents["ingestKey"]; 9 | name: string; 10 | } 11 | 12 | type TrackedButtonProps = React.ComponentProps & TrackProps; 13 | 14 | export const TrackedButton = ({ 15 | analyticsKey, 16 | children, 17 | onClick, 18 | name, 19 | ref, 20 | ...props 21 | }: TrackedButtonProps) => { 22 | return ( 23 | 37 | ); 38 | }; 39 | 40 | type TrackedButtonLinkProps = React.ComponentProps & TrackProps; 41 | 42 | export const TrackedButtonLink = ({ 43 | analyticsKey, 44 | children, 45 | onClick, 46 | name, 47 | ref, 48 | ...props 49 | }: TrackedButtonLinkProps) => { 50 | return ( 51 | { 55 | sendEvent(analyticsKey, { 56 | eventType: name, 57 | }); 58 | if (onClick) { 59 | onClick(e); 60 | } 61 | }} 62 | > 63 | {children} 64 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/app/changelog/rss.xml/route.ts: -------------------------------------------------------------------------------- 1 | import { siteUrl } from "@/lib/constants"; 2 | import { basehub } from "basehub"; 3 | 4 | export async function GET() { 5 | const data = await basehub().query({ 6 | site: { 7 | changelog: { 8 | title: true, 9 | subtitle: true, 10 | posts: { 11 | __args: { 12 | orderBy: "publishedAt__DESC", 13 | }, 14 | items: { 15 | _title: true, 16 | _slug: true, 17 | excerpt: true, 18 | publishedAt: true, 19 | }, 20 | }, 21 | }, 22 | }, 23 | }); 24 | 25 | const feed = ` 26 | 27 | 28 | ${data.site.changelog.title} 29 | ${data.site.changelog.subtitle} 30 | ${siteUrl}/changelog 31 | en-us${data.site.changelog.posts.items 32 | .map((post) => { 33 | return ` 34 | 35 | ${post._title} 36 | ${siteUrl}/changelog/${post._slug} 37 | ${post.excerpt} 38 | ${post.publishedAt} 39 | `; 40 | }) 41 | .join("")} 42 | 43 | `; 44 | 45 | return new Response(feed, { 46 | status: 200, 47 | headers: { "Content-Type": "application/rss+xml" }, 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basehub-marketing-website-template", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "dev": "basehub dev & next dev --turbopack", 6 | "build": "basehub && next build", 7 | "start": "next start", 8 | "lint": "next lint" 9 | }, 10 | "dependencies": { 11 | "@radix-ui/react-accordion": "^1.2.1", 12 | "@radix-ui/react-icons": "^1.3.0", 13 | "@radix-ui/react-navigation-menu": "^1.2.1", 14 | "@radix-ui/react-popover": "^1.1.2", 15 | "@radix-ui/react-select": "^2.1.2", 16 | "@radix-ui/react-slot": "^1.1.0", 17 | "@radix-ui/react-tooltip": "^1.1.3", 18 | "@tailwindcss/postcss": "^4.0.17", 19 | "basehub": "^9.5.2", 20 | "class-variance-authority": "^0.7.0", 21 | "clsx": "^2.1.1", 22 | "culori": "^4.0.1", 23 | "embla-carousel": "^8.0.4", 24 | "embla-carousel-react": "^8.0.4", 25 | "embla-carousel-wheel-gestures": "^8.0.1", 26 | "hast-util-to-jsx-runtime": "^2.3.0", 27 | "next": "16.0.9", 28 | "next-themes": "^0.4.6", 29 | "postcss": "^8.4.38", 30 | "react": "19.2.3", 31 | "react-dom": "19.2.3", 32 | "sass": "^1.83.0", 33 | "tailwindcss": "^4.0.17", 34 | "tailwindcss-radix": "^4.0.2" 35 | }, 36 | "devDependencies": { 37 | "@tailwindcss/typography": "^0.5.13", 38 | "@types/culori": "^2.1.1", 39 | "@types/node": "^20", 40 | "@types/react": "^19", 41 | "@types/react-dom": "^19", 42 | "eslint": "^8", 43 | "eslint-config-next": "15.0.1", 44 | "prettier": "^3.2.5", 45 | "prettier-plugin-tailwindcss": "^0.6.11", 46 | "typescript": "^5.4.5" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @config '../../tailwind.config.ts'; 4 | 5 | /* 6 | The default border color has changed to `currentColor` in Tailwind CSS v4, 7 | so we've added these compatibility styles to make sure everything still 8 | looks the same as it did with Tailwind CSS v3. 9 | 10 | If we ever want to remove these styles, we need to add an explicit border 11 | color utility to any element that depends on these defaults. 12 | */ 13 | @layer base { 14 | *, 15 | ::after, 16 | ::before, 17 | ::backdrop, 18 | ::file-selector-button { 19 | border-color: var(--gray-200, currentColor); 20 | } 21 | } 22 | 23 | :root { 24 | --header-height: 64px; 25 | 26 | text-rendering: geometricprecision; 27 | -webkit-text-size-adjust: 100%; 28 | -webkit-font-smoothing: antialiased; 29 | -moz-font-smoothing: antialiased; 30 | -moz-osx-font-smoothing: grayscale; 31 | } 32 | 33 | :root:not(html.dark) { 34 | /* colors */ 35 | color: var(--text-primary); 36 | background-color: var(--surface-primary); 37 | } 38 | 39 | :root:not(html.dark) ::selection { 40 | background-color: var(--accent-200); 41 | } 42 | 43 | :root:not(html.dark) *:focus-visible { 44 | outline-color: var(--accent-300); 45 | } 46 | 47 | :root { 48 | color: var(--dark-text-primary); 49 | background-color: var(--dark-surface-primary); 50 | } 51 | 52 | :root ::selection { 53 | background-color: var(--accent-950); 54 | } 55 | 56 | :root *:focus-visible { 57 | outline-color: var(--accent-400); 58 | } 59 | 60 | .no-scrollbar::-webkit-scrollbar { 61 | display: none; 62 | } 63 | -------------------------------------------------------------------------------- /src/app/_sections/callout-2/index.tsx: -------------------------------------------------------------------------------- 1 | import { fragmentOn } from "basehub"; 2 | import { Section } from "@/common/layout"; 3 | import { TrackedButtonLink } from "@/app/_components/tracked_button"; 4 | import { buttonFragment } from "@/lib/basehub/fragments"; 5 | import { GeneralEvents } from "@/../basehub-types"; 6 | 7 | export const calloutv2Fragment = fragmentOn("CalloutV2Component", { 8 | title: true, 9 | subtitle: true, 10 | _analyticsKey: true, 11 | actions: buttonFragment, 12 | }); 13 | type Callout2 = fragmentOn.infer; 14 | 15 | export function Callout2(callout: Callout2 & { eventsKey: GeneralEvents["ingestKey"] }) { 16 | return ( 17 |
18 |
19 |
20 |

21 | {callout.title} 22 |

23 |

24 | {callout.subtitle} 25 |

26 |
27 |
28 | {callout.actions?.map((action) => ( 29 | 36 | {action.label} 37 | 38 | ))} 39 |
40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { siteUrl } from "@/lib/constants"; 2 | import { basehub } from "basehub"; 3 | import type { MetadataRoute } from "next"; 4 | 5 | export const revalidate = 1800; // 30 minutes - adjust as needed 6 | 7 | export default async function sitemap(): Promise { 8 | const data = await basehub().query({ 9 | site: { 10 | pages: { 11 | items: { 12 | pathname: true, 13 | }, 14 | }, 15 | blog: { 16 | posts: { 17 | items: { 18 | _slug: true, 19 | }, 20 | }, 21 | }, 22 | changelog: { 23 | posts: { 24 | items: { 25 | _slug: true, 26 | }, 27 | }, 28 | }, 29 | }, 30 | }); 31 | 32 | let index = 1; 33 | const formattedPages = data.site.pages.items.map( 34 | (page) => 35 | ({ 36 | url: `${siteUrl}${page.pathname}`, 37 | lastModified: new Date(), 38 | changeFrequency: "daily", 39 | priority: index++, 40 | }) satisfies MetadataRoute.Sitemap[number], 41 | ); 42 | 43 | const formattedBlogPosts = data.site.blog.posts.items.map( 44 | (post) => 45 | ({ 46 | url: `${siteUrl}/blog/${post._slug}`, 47 | lastModified: new Date(), 48 | changeFrequency: "daily", 49 | priority: index++, 50 | }) satisfies MetadataRoute.Sitemap[number], 51 | ); 52 | 53 | const formattedChangelogPosts = data.site.changelog.posts.items.map( 54 | (post) => 55 | ({ 56 | url: `${siteUrl}/changelog/${post._slug}`, 57 | lastModified: new Date(), 58 | changeFrequency: "daily", 59 | priority: index++, 60 | }) satisfies MetadataRoute.Sitemap[number], 61 | ); 62 | 63 | const routes = [...formattedPages, ...formattedBlogPosts, ...formattedChangelogPosts]; 64 | return routes; 65 | } 66 | -------------------------------------------------------------------------------- /src/app/_components/code-snippet/index.tsx: -------------------------------------------------------------------------------- 1 | import { CodeBlock, type Language, createCssVariablesTheme } from "basehub/react-code-block"; 2 | 3 | import { fragmentOn } from "basehub"; 4 | 5 | import { CopyButton } from "./copy-button"; 6 | import { languagesIcons } from "./language"; 7 | import { FileIcon } from "@radix-ui/react-icons"; 8 | import s from "./code-snippet.module.scss"; 9 | 10 | export const codeSnippetFragment = fragmentOn("CodeSnippetComponent", { 11 | _id: true, 12 | code: { 13 | code: true, 14 | language: true, 15 | }, 16 | _title: true, 17 | }); 18 | 19 | export type CodeSnippetFragment = fragmentOn.infer; 20 | 21 | export function CodeSnippet({ code, _id, _title = "Untitled" }: CodeSnippetFragment) { 22 | return ( 23 |
24 | 27 |
28 | 29 | {languagesIcons[code.language as Language] ?? } 30 | 31 | {_title} 32 |
33 | 34 | 35 | } 36 | components={{ 37 | div: ({ children, ...rest }) => ( 38 |
39 | {children} 40 |
41 | ), 42 | }} 43 | lineNumbers={{ className: "line-indicator" }} 44 | snippets={[{ code: code.code, language: code.language as Language, id: _id }]} 45 | theme={theme} 46 | /> 47 |
48 | ); 49 | } 50 | 51 | const theme = createCssVariablesTheme({ 52 | name: "css-variables", 53 | variablePrefix: "--shiki-", 54 | variableDefaults: {}, 55 | fontStyle: true, 56 | }); 57 | -------------------------------------------------------------------------------- /src/app/_sections/companies/index.tsx: -------------------------------------------------------------------------------- 1 | import { BaseHubImage } from "basehub/next-image"; 2 | import clsx from "clsx"; 3 | 4 | import { Section } from "@/common/layout"; 5 | import { fragmentOn } from "basehub"; 6 | 7 | import s from "./companies.module.scss"; 8 | 9 | export const companiesFragment = fragmentOn("CompaniesComponent", { 10 | subtitle: true, 11 | companies: { 12 | _title: true, 13 | url: true, 14 | image: { 15 | url: true, 16 | }, 17 | }, 18 | }); 19 | 20 | type Companies = fragmentOn.infer; 21 | 22 | export function Companies(props: Companies) { 23 | return ( 24 |
25 |

26 | {props.subtitle} 27 |

28 |
29 |
30 |
31 |
34 | {props.companies.map((company) => ( 35 |
39 | 46 |
47 | ))} 48 |
49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/context/basehub-theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import { Pump } from "basehub/react-pump"; 2 | import { fragmentOn } from "basehub"; 3 | import colors from "tailwindcss/colors"; 4 | import { oklch, rgb } from "culori"; 5 | 6 | export const themeFragment = fragmentOn("Theme", { accent: true, grayScale: true }); 7 | 8 | export type BaseHubTheme = fragmentOn.infer; 9 | 10 | const CONTRAST_WARNING_COLORS: (keyof typeof colors)[] = [ 11 | "amber", 12 | "cyan", 13 | "green", 14 | "lime", 15 | "yellow", 16 | ]; 17 | 18 | function anyColorToRgb(color: string) { 19 | const parsed = oklch(color); // or use parse() for any format 20 | const converted = rgb(parsed); 21 | if (!converted) throw new Error(`Invalid color format: ${color}`); 22 | return { 23 | r: Math.round(converted.r * 255), 24 | g: Math.round(converted.g * 255), 25 | b: Math.round(converted.b * 255), 26 | }; 27 | } 28 | 29 | export function BaseHubThemeProvider() { 30 | return ( 31 | 32 | {async ([data]) => { 33 | "use server"; 34 | const accent = colors[data.site.settings.theme.accent]; 35 | const grayScale = colors[data.site.settings.theme.grayScale]; 36 | 37 | const css = Object.entries(accent).map(([key, value]) => { 38 | const rgb = anyColorToRgb(value); 39 | 40 | return `--accent-${key}: ${value}; --accent-rgb-${key}: ${rgb.r}, ${rgb.g}, ${rgb.b};`; 41 | }); 42 | 43 | Object.entries(grayScale).forEach(([key, value]) => { 44 | const rgb = anyColorToRgb(value); 45 | 46 | css.push( 47 | `--grayscale-${key}: ${value}; --grayscale-rgb-${key}: ${rgb.r}, ${rgb.g}, ${rgb.b};`, 48 | ); 49 | }); 50 | if (CONTRAST_WARNING_COLORS.includes(data.site.settings.theme.accent)) { 51 | css.push(`--text-on-accent: ${colors.gray[950]};`); 52 | } 53 | 54 | return ( 55 | 60 | ); 61 | }} 62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/app/_components/labeled-input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { clsx } from "clsx"; 3 | 4 | export function LabeledInput({ 5 | label, 6 | id: _id, 7 | className, 8 | ...props 9 | }: { label: string; id?: string } & React.InputHTMLAttributes) { 10 | const reactId = React.useId(); 11 | const id = _id ?? reactId; 12 | 13 | return ( 14 | 15 | 23 | 24 | ); 25 | } 26 | 27 | export const LabeledTextarea = ({ 28 | label, 29 | id: _id, 30 | ref, 31 | className, 32 | ...props 33 | }: { label: string } & React.JSX.IntrinsicElements["textarea"]) => { 34 | const reactId = React.useId(); 35 | const id = _id ?? reactId; 36 | 37 | return ( 38 | 39 |