60 |
61 |
68 | {formatDate(post.publishedAt)}
69 |
70 |
75 |
= idx,
80 | "delay-500": prevPostIdx === activeIdx - 1,
81 | },
82 | )}
83 | />
84 |
idx && "scale-y-100 delay-150",
89 | )}
90 | />
91 |
92 |
93 |
94 |
104 |
105 |
106 | {post._title}
107 |
108 |
109 | {post.excerpt}
110 |
111 |
112 |
143 |
144 |
145 | ))}
146 |
147 | );
148 | }
149 |
--------------------------------------------------------------------------------
/src/app/_sections/testimonials/slider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { type EmblaCarouselType } from "embla-carousel";
3 | import useEmblaCarousel from "embla-carousel-react";
4 | import * as React from "react";
5 | import { BaseHubImage } from "basehub/next-image";
6 | import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons";
7 | import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
8 | import clsx from "clsx";
9 |
10 | import { type TestimonialsSlider } from ".";
11 | import { Button } from "@/common/button";
12 |
13 | export function Slider({
14 | quotes,
15 | children,
16 | }: {
17 | quotes: TestimonialsSlider["quotes"];
18 | children: React.ReactNode;
19 | }) {
20 | const [emblaRef, emblaApi] = useEmblaCarousel(
21 | {
22 | align: "start",
23 | breakpoints: {
24 | 640: {
25 | align: "center",
26 | },
27 | },
28 | },
29 | [WheelGesturesPlugin()],
30 | );
31 |
32 | const [selectedIndex, setSelectedIndex] = React.useState(0);
33 | const [scrollSnaps, setScrollSnaps] = React.useState
([]);
34 |
35 | const onDotButtonClick = React.useCallback(
36 | (index: number) => {
37 | if (!emblaApi) return;
38 | emblaApi.scrollTo(index);
39 | },
40 | [emblaApi],
41 | );
42 |
43 | const onInit = React.useCallback((emblaApi: EmblaCarouselType) => {
44 | setScrollSnaps(emblaApi.scrollSnapList());
45 | }, []);
46 |
47 | const onSelect = React.useCallback((emblaApi: EmblaCarouselType) => {
48 | setSelectedIndex(emblaApi.selectedScrollSnap());
49 | }, []);
50 |
51 | const onPrevButtonClick = React.useCallback(() => {
52 | if (!emblaApi) return;
53 | emblaApi.scrollPrev();
54 | }, [emblaApi]);
55 |
56 | const onNextButtonClick = React.useCallback(() => {
57 | if (!emblaApi) return;
58 | emblaApi.scrollNext();
59 | }, [emblaApi]);
60 |
61 | React.useEffect(() => {
62 | if (!emblaApi) return;
63 |
64 | onSelect(emblaApi);
65 | emblaApi.on("reInit", onSelect);
66 | emblaApi.on("select", onSelect);
67 | }, [emblaApi, onSelect]);
68 |
69 | React.useEffect(() => {
70 | if (!emblaApi) return;
71 |
72 | onInit(emblaApi);
73 | onSelect(emblaApi);
74 | emblaApi.on("reInit", onInit);
75 | emblaApi.on("reInit", onSelect);
76 | emblaApi.on("select", onSelect);
77 | }, [emblaApi, onInit, onSelect]);
78 |
79 | return (
80 |
81 |
82 | {children}
83 |
84 |
92 |
100 |
101 |
102 |
103 |
104 | {quotes.map((item) => (
105 |
106 | ))}
107 |
108 |
109 | {scrollSnaps.map((snap, index) => (
110 |
128 | ))}
129 |
130 |
131 |
132 | );
133 | }
134 |
135 | export function VainillaCard({ quote, author }: TestimonialsSlider["quotes"][0]) {
136 | return (
137 |
138 |
139 |
140 |
141 | “{quote}”
142 |
143 |
144 |
145 |
146 |
153 |
154 |
{author._title}
155 |
156 | {author._title}, {author.company._title}
157 |
158 |
159 |
160 |
161 | {author.company.image ? (
162 |
169 | ) : null}
170 |
171 |
172 |
173 |
174 | );
175 | }
176 |
177 | export const TesimonialCard = React.memo(
178 | VainillaCard,
179 | (prevProps, nextProps) =>
180 | prevProps.quote === nextProps.quote && prevProps.author === nextProps.author,
181 | );
182 |
--------------------------------------------------------------------------------
/src/app/_sections/features/hero/index.tsx:
--------------------------------------------------------------------------------
1 | import { BaseHubImage } from "basehub/next-image";
2 |
3 | import { fragmentOn } from "basehub";
4 | import { Heading } from "@/common/heading";
5 | import { Section } from "@/common/layout";
6 | import { darkLightImageFragment, headingFragment } from "@/lib/basehub/fragments";
7 | import { Pump } from "basehub/react-pump";
8 | import clsx from "clsx";
9 | import { DarkLightImage } from "@/common/dark-light-image";
10 | import { TrackedButtonLink } from "@/app/_components/tracked_button";
11 |
12 | import s from "./hero.module.scss";
13 | import { GeneralEvents } from "@/../basehub-types";
14 |
15 | export const featureHeroFragment = fragmentOn("FeatureHeroComponent", {
16 | _analyticsKey: true,
17 | heroLayout: true,
18 | heading: headingFragment,
19 | image: darkLightImageFragment,
20 | actions: {
21 | _id: true,
22 | href: true,
23 | label: true,
24 | type: true,
25 | },
26 | });
27 |
28 | type FeatureHero = fragmentOn.infer;
29 |
30 | export default function FeatureHero({
31 | heading,
32 | heroLayout,
33 | image,
34 | actions,
35 | eventsKey,
36 | }: FeatureHero & { eventsKey: GeneralEvents["ingestKey"] }) {
37 | switch (heroLayout) {
38 | case "Image bottom": {
39 | return (
40 |
41 |
42 |
43 | {heading.title}
44 |
45 |
46 | {actions?.map((action) => (
47 |
55 | {action.label}
56 |
57 | ))}
58 |
59 |
60 |
65 |
66 | );
67 | }
68 | case "Image Right": {
69 | return (
70 |
71 |
72 |
73 |
74 | {heading.title}
75 |
76 |
77 | {actions?.map((action) => (
78 |
86 | {action.label}
87 |
88 | ))}
89 |
90 |
91 |
96 |
97 |
98 | );
99 | }
100 | case "full image": {
101 | return (
102 | <>
103 |
108 |
109 |
110 |
111 | {heading.title}
112 |
113 | {actions && actions.length > 0 ? (
114 |
115 | {actions.map((action) => (
116 |
124 | {action.label}
125 |
126 | ))}
127 |
128 | ) : null}
129 |
130 |
131 | >
132 | );
133 | }
134 | case "gradient": {
135 | return (
136 |
137 |
138 |
154 | {async ([{ site }]) => {
155 | "use server";
156 |
157 | return (
158 |
166 | );
167 | }}
168 |
169 |
170 | {heading.title}
171 |
172 |
173 | {actions
174 | ? actions.map((action) => (
175 |
183 | {action.label}
184 |
185 | ))
186 | : null}
187 |
188 |
189 | {/* Gradient */}
190 |
196 |
197 | );
198 | }
199 | default: {
200 | return null;
201 | }
202 | }
203 | }
204 |
--------------------------------------------------------------------------------
/src/app/blog/[slug]/page.tsx:
--------------------------------------------------------------------------------
1 | import { draftMode } from "next/headers";
2 | import { notFound } from "next/navigation";
3 | import { RichText } from "basehub/react-rich-text";
4 | import type { Metadata } from "next";
5 |
6 | import { Pump } from "basehub/react-pump";
7 | import { Section } from "@/common/layout";
8 | import { authorFragment, darkLightImageFragment } from "@/lib/basehub/fragments";
9 | import { Heading } from "@/common/heading";
10 | import { Avatar } from "@/common/avatar";
11 | import {
12 | FaqItemComponentFragment,
13 | FaqRichtextComponent,
14 | richTextBaseComponents,
15 | RichTextCalloutComponent,
16 | richTextCalloutComponentFragment,
17 | richTextClasses,
18 | } from "@/app/_components/rich-text";
19 | import { CodeSnippet, codeSnippetFragment } from "@/app/_components/code-snippet";
20 | import { basehub } from "basehub";
21 | import { cx } from "class-variance-authority";
22 | import { formatDate } from "@/utils/dates";
23 | import { DarkLightImage } from "@/common/dark-light-image";
24 | import { PageView } from "@/app/_components/page-view";
25 |
26 | export const dynamic = "force-static";
27 |
28 | export const generateStaticParams = async () => {
29 | const data = await basehub({ cache: "no-store" }).query({
30 | site: {
31 | blog: {
32 | posts: {
33 | items: {
34 | _slug: true,
35 | },
36 | },
37 | },
38 | },
39 | });
40 |
41 | return data.site.blog.posts.items.map((post) => {
42 | return {
43 | slug: post._slug,
44 | };
45 | });
46 | };
47 |
48 | export const generateMetadata = async ({
49 | params: _params,
50 | }: {
51 | params: Promise<{ slug: string }>;
52 | }): Promise => {
53 | const { slug } = await _params;
54 | const data = await basehub({ draft: (await draftMode()).isEnabled }).query({
55 | site: {
56 | settings: {
57 | metadata: {
58 | titleTemplate: true,
59 | sitename: true,
60 | },
61 | },
62 | blog: {
63 | posts: {
64 | __args: {
65 | filter: {
66 | _sys_slug: { eq: slug },
67 | },
68 | first: 1,
69 | },
70 | items: {
71 | ogImage: { url: true },
72 | _id: true,
73 | _title: true,
74 | description: true,
75 | },
76 | },
77 | },
78 | },
79 | });
80 |
81 | const post = data.site.blog.posts.items[0];
82 |
83 | if (!post) return undefined;
84 | const images = [{ url: post.ogImage.url }];
85 |
86 | return {
87 | title: post._title,
88 | description: post.description,
89 | openGraph: {
90 | images,
91 | type: "article",
92 | },
93 | twitter: {
94 | images,
95 | card: "summary_large_image",
96 | site: data.site.settings.metadata.sitename,
97 | },
98 | };
99 | };
100 |
101 | export default async function BlogPage({ params: _params }: { params: Promise<{ slug: string }> }) {
102 | const { slug } = await _params;
103 | return (
104 |
105 |
147 | {async ([
148 | {
149 | site: {
150 | generalEvents,
151 | blog: { posts },
152 | },
153 | },
154 | ]) => {
155 | "use server";
156 | const blogpost = posts.items.at(0);
157 |
158 | if (!blogpost) return notFound();
159 |
160 | return (
161 | <>
162 |
163 |
164 |
165 | {blogpost._title}
166 |
167 |
168 |
169 | {blogpost.authors.map((author) => (
170 |
171 |
172 | {author._title}
173 |
174 | ))}
175 |
176 |
177 |
{formatDate(blogpost.publishedAt)}
178 |
179 | {blogpost.categories.map((category) => (
180 |
181 | {category}
182 |
183 | ))}
184 |
185 |
186 |
187 |
188 |
195 |
196 | p:first-child]:text-2xl [&>p:first-child]:font-light",
200 | )}
201 | >
202 |
211 | {blogpost.body.json.content}
212 |
213 |
214 |
215 | >
216 | );
217 | }}
218 |
219 |
220 | );
221 | }
222 |
--------------------------------------------------------------------------------
/src/app/_sections/pricing-comparation/mobile-pricing-comparison.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import clsx from "clsx";
3 | import * as React from "react";
4 | import {
5 | Select,
6 | SelectTrigger,
7 | SelectContent,
8 | SelectViewport,
9 | SelectItem,
10 | SelectItemIndicator,
11 | SelectPortal,
12 | SelectValue,
13 | SelectIcon,
14 | SelectItemText,
15 | } from "@radix-ui/react-select";
16 | import {
17 | Accordion,
18 | AccordionContent,
19 | AccordionItem,
20 | AccordionTrigger,
21 | } from "@radix-ui/react-accordion";
22 | import {
23 | CaretSortIcon,
24 | CheckIcon,
25 | ChevronDownIcon,
26 | QuestionMarkCircledIcon,
27 | } from "@radix-ui/react-icons";
28 | import { SimpleTooltip } from "@/common/tooltip";
29 | import { type PlanFragment, type PricingTableProps } from ".";
30 |
31 | export function MobilePricingComparison({
32 | categories,
33 | plans,
34 | }: Pick & {
35 | plans: PlanFragment[];
36 | }) {
37 | const [activePlan, setActivePlan] = React.useState(plans[0]?._id ?? "");
38 |
39 | const selectedPlan = React.useMemo(
40 | () => plans.find((plan) => plan._id === activePlan),
41 | [activePlan, plans],
42 | );
43 |
44 | return (
45 |
46 |
47 |
103 |
104 |
109 | {categories.items.map((category) => (
110 |
115 |
116 | {category._title}
117 |
118 |
123 |
124 |
125 |
126 |
127 |
128 | {category.features.items.map((feature) => (
129 |
133 | |
134 | {feature._title}
135 | {feature.tooltip ? (
136 |
137 |
138 |
139 |
140 |
141 | ) : null}
142 | |
143 |
144 |
145 | ))}
146 |
147 |
148 |
149 |
150 | ))}
151 |
152 |
153 | );
154 | }
155 |
156 | function FeatureValue({
157 | feature,
158 | activePlan,
159 | }: {
160 | feature: PricingTableProps["categories"]["items"][number]["features"]["items"][number];
161 | activePlan: string;
162 | }) {
163 | const value = feature.values.items.find((value) => value.plan._id === activePlan);
164 |
165 | if (!value) return null;
166 |
167 | return (
168 |
169 | {value.value?.__typename === "BooleanComponent" ? (
170 | value.value.boolean ? (
171 |
172 |
173 |
174 | ) : (
175 | —
176 | )
177 | ) : value.value?.__typename === "CustomTextComponent" ? (
178 |
179 | {value.value.text}
180 |
181 | ) : (
182 | {value.value}
183 | )}
184 | |
185 | );
186 | }
187 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | import typography from "@tailwindcss/typography";
4 | import radix from "tailwindcss-radix";
5 |
6 | const config: Config = {
7 | content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
8 | darkMode: "class",
9 | theme: {
10 | fontFamily: {
11 | sans: "var(--font-sans)",
12 | mono: "var(--font-mono)",
13 | },
14 | colors: {
15 | // Dynamic colors
16 | black: "#000",
17 | white: "#fff",
18 | transparent: "transparent",
19 | accent: {
20 | 50: "rgba(var(--accent-rgb-50), )",
21 | 100: "rgba(var(--accent-rgb-100), )",
22 | 200: "rgba(var(--accent-rgb-200), )",
23 | 300: "rgba(var(--accent-rgb-300), )",
24 | 400: "rgba(var(--accent-rgb-400), )",
25 | 500: "rgba(var(--accent-rgb-500), )",
26 | 600: "rgba(var(--accent-rgb-600), )",
27 | 700: "rgba(var(--accent-rgb-700), )",
28 | 800: "rgba(var(--accent-rgb-800), )",
29 | 900: "rgba(var(--accent-rgb-900), )",
30 | 950: "rgba(var(--accent-rgb-950), )",
31 | },
32 | grayscale: {
33 | 50: "rgba(var(--grayscale-rgb-50), )",
34 | 100: "rgba(var(--grayscale-rgb-100), )",
35 | 200: "rgba(var(--grayscale-rgb-200), )",
36 | 300: "rgba(var(--grayscale-rgb-300), )",
37 | 400: "rgba(var(--grayscale-rgb-400), )",
38 | 500: "rgba(var(--grayscale-rgb-500), )",
39 | 600: "rgba(var(--grayscale-rgb-600), )",
40 | 700: "rgba(var(--grayscale-rgb-700), )",
41 | 800: "rgba(var(--grayscale-rgb-800), )",
42 | 900: "rgba(var(--grayscale-rgb-900), )",
43 | 950: "rgba(var(--grayscale-rgb-950), )",
44 | },
45 | ["text-on-accent"]: {
46 | primary: "var(--text-on-accent, var(--grayscale-50))",
47 | },
48 | dark: {
49 | text: {
50 | primary: "rgba(var(--grayscale-rgb-50), )",
51 | secondary: "rgba(var(--grayscale-rgb-400), )",
52 | tertiary: "rgba(var(--grayscale-rgb-500), )",
53 | },
54 | surface: {
55 | primary: "rgba(var(--grayscale-rgb-950), )",
56 | secondary: "rgba(var(--grayscale-rgb-900), )",
57 | tertiary: "rgba(var(--grayscale-rgb-800), )",
58 | },
59 | border: {
60 | DEFAULT: "rgba(var(--grayscale-rgb-800), )",
61 | },
62 | control: {
63 | DEFAULT: "rgba(var(--accent-rgb-500), )",
64 | },
65 | },
66 | text: {
67 | primary: "rgba(var(--grayscale-rgb-950), )",
68 | secondary: "rgba(var(--grayscale-rgb-600), )",
69 | tertiary: "rgba(var(--grayscale-rgb-500), )",
70 | },
71 | surface: {
72 | primary: "rgba(var(--grayscale-rgb-50), )",
73 | secondary: "rgba(var(--grayscale-rgb-100), )",
74 | tertiary: "rgba(var(--grayscale-rgb-200), )",
75 | },
76 | border: {
77 | DEFAULT: "rgba(var(--grayscale-rgb-300), )",
78 | },
79 | control: {
80 | DEFAULT: "rgba(var(--accent-rgb-500), )",
81 | },
82 | error: {
83 | DEFAULT: "#FF453A",
84 | },
85 | success: {
86 | DEFAULT: "#14C9A2",
87 | },
88 | },
89 | fontSize: {
90 | "2xs": ["11px", { lineHeight: "1.3", letterSpacing: "-0.3px", fontWeight: "300" }],
91 | xs: ["0.75rem", { lineHeight: "1rem", letterSpacing: "-0.36px", fontWeight: "300" }],
92 | sm: ["0.875rem", { lineHeight: "1.25rem", letterSpacing: "-0.42px" }],
93 | base: ["1rem", { lineHeight: "1.6", letterSpacing: "-0.48px" }],
94 | lg: ["1.125rem", { lineHeight: "1.75rem", letterSpacing: "-0.72px" }],
95 | xl: ["1.25rem", { lineHeight: "1.75rem", letterSpacing: "-0.8px" }],
96 | "2xl": ["1.5rem", { lineHeight: "2rem", letterSpacing: "-1.04px" }],
97 | "3xl": ["2rem", { lineHeight: "2.25rem", letterSpacing: "-1.2px" }],
98 | "4xl": ["2.25rem", { lineHeight: "2.5rem", letterSpacing: "-1.44px" }],
99 | "5xl": ["3rem", { letterSpacing: "-1.6px" }],
100 | "6xl": ["3.75rem", { letterSpacing: "-1.8px" }],
101 | "7xl": ["4.5rem", { letterSpacing: "-2px" }],
102 | "8xl": ["6rem", { letterSpacing: "-2.4px" }],
103 | "9xl": ["8rem", { letterSpacing: "-3.2px" }],
104 | },
105 | extend: {
106 | maxWidth: {
107 | prose: "75ch",
108 | },
109 | gridTemplateColumns: {
110 | header: "1fr max-content 1fr",
111 | },
112 | boxShadow: {
113 | neon: "0 0 2px 2px var(--tw-shadow), 0 0 6px 3px var(--tw-ring-offset-shadow), 0 0 8px 4px var(--tw-ring-shadow)",
114 | },
115 | zIndex: {
116 | modal: "9999",
117 | },
118 | keyframes: {
119 | enterFromRight: {
120 | from: { opacity: "0", transform: "translateX(200px)" },
121 | to: { opacity: "1", transform: "translateX(0)" },
122 | },
123 | enterFromLeft: {
124 | from: { opacity: "0", transform: "translateX(-200px)" },
125 | to: { opacity: "1", transform: "translateX(0)" },
126 | },
127 | exitToRight: {
128 | from: { opacity: "1", transform: "translateX(0)" },
129 | to: { opacity: "0", transform: "translateX(200px)" },
130 | },
131 | exitToLeft: {
132 | from: { opacity: "1", transform: "translateX(0)" },
133 | to: { opacity: "0", transform: "translateX(-200px)" },
134 | },
135 | scaleIn: {
136 | from: { opacity: "0", transform: "rotateX(-10deg) scale(0.9)" },
137 | to: { opacity: "1", transform: "rotateX(0deg) scale(1)" },
138 | },
139 | scaleOut: {
140 | from: { opacity: "1", transform: "rotateX(0deg) scale(1)" },
141 | to: { opacity: "0", transform: "rotateX(-10deg) scale(0.95)" },
142 | },
143 | fadeIn: {
144 | from: { opacity: "0" },
145 | to: { opacity: "1" },
146 | },
147 | fadeOut: {
148 | from: { opacity: "1" },
149 | to: { opacity: "0" },
150 | },
151 | slideDown: {
152 | from: { height: "0px" },
153 | to: { height: "var(--radix-accordion-content-height)" },
154 | },
155 | slideUp: {
156 | from: { height: "var(--radix-accordion-content-height)" },
157 | to: { height: "0px" },
158 | },
159 | pulse: {
160 | "50%": {
161 | opacity: "0.5",
162 | },
163 | },
164 | },
165 | letterSpacing: {
166 | tighter: "-0.58px",
167 | tight: "-0.48px",
168 | },
169 | typography: {
170 | DEFAULT: {
171 | css: {
172 | p: {
173 | letterSpacing: "-0.48px",
174 | },
175 | code: {
176 | letterSpacing: "normal",
177 | },
178 | },
179 | },
180 | },
181 | },
182 | animation: {
183 | slideDown: "slideDown 300ms cubic-bezier(0.87, 0, 0.13, 1)",
184 | slideUp: "slideUp 300ms cubic-bezier(0.87, 0, 0.13, 1)",
185 | scaleIn: "scaleIn 200ms ease",
186 | scaleOut: "scaleOut 200ms ease",
187 | fadeIn: "fadeIn 200ms ease",
188 | fadeOut: "fadeOut 200ms ease",
189 | enterFromLeft: "enterFromLeft 250ms ease",
190 | enterFromRight: "enterFromRight 250ms ease",
191 | exitToLeft: "exitToLeft 250ms ease",
192 | exitToRight: "exitToRight 250ms ease",
193 | pulse: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
194 | },
195 | },
196 | plugins: [typography, radix],
197 | };
198 |
199 | export default config;
200 |
--------------------------------------------------------------------------------
/src/app/[[...slug]]/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 |
3 | import { draftMode } from "next/headers";
4 | import { notFound } from "next/navigation";
5 |
6 | import { Pump } from "basehub/react-pump";
7 | import { GeneralEvents } from "@/../basehub-types";
8 | import { basehub, fragmentOn } from "basehub";
9 |
10 | import { AccordionFaq } from "../_sections/accordion-faq";
11 | import { BigFeature, bigFeatureFragment } from "../_sections/features/big-feature";
12 | import { Callout, calloutFragment } from "../_sections/callout-1";
13 | import { Callout2, calloutv2Fragment } from "../_sections/callout-2";
14 | import { Companies, companiesFragment } from "../_sections/companies";
15 | import { Faq, faqFragment } from "../_sections/faq";
16 | import { FeaturesGrid, featuresGridFragment } from "../_sections/features/features-grid";
17 | import { FeaturesList, featureCardsComponent } from "../_sections/features/features-list";
18 | import { Hero, heroFragment } from "../_sections/hero";
19 | import { Pricing, pricingFragment } from "../_sections/pricing";
20 | import { SideFeatures, featuresSideBySideFragment } from "../_sections/features/side-features";
21 | import { Testimonials, testimonialsSliderFragment } from "../_sections/testimonials";
22 | import { TestimonialsGrid, testimonialsGridFragment } from "../_sections/testimonials-grid";
23 | import { PricingTable } from "../_sections/pricing-comparation";
24 | import { pricingTableFragment } from "../_sections/pricing-comparation/fragments";
25 | import FeatureHero, { featureHeroFragment } from "../_sections/features/hero";
26 | import { PageView } from "../_components/page-view";
27 | import { FreeformText, freeformTextFragment } from "../_sections/freeform-text";
28 | import { Form, formFragment } from "../_sections/form";
29 |
30 | export const dynamic = "force-static";
31 |
32 | export const generateStaticParams = async () => {
33 | const data = await basehub().query({
34 | site: {
35 | pages: {
36 | items: {
37 | pathname: true,
38 | },
39 | },
40 | },
41 | });
42 |
43 | return data.site.pages.items.map((item) => ({
44 | slug: item.pathname.split("/").filter(Boolean),
45 | }));
46 | };
47 |
48 | export const generateMetadata = async ({
49 | params: _params,
50 | }: {
51 | params: Promise<{ slug?: string[] }>;
52 | }): Promise => {
53 | const params = await _params;
54 | const data = await basehub({ draft: (await draftMode()).isEnabled }).query({
55 | site: {
56 | settings: { metadata: { defaultTitle: true, titleTemplate: true, defaultDescription: true } },
57 | pages: {
58 | __args: {
59 | filter: {
60 | pathname: {
61 | eq: params.slug ? `/${params.slug.join("/")}` : "/",
62 | },
63 | },
64 | },
65 | items: {
66 | metadataOverrides: {
67 | title: true,
68 | description: true,
69 | },
70 | },
71 | },
72 | },
73 | });
74 |
75 | const page = data.site.pages.items.at(0);
76 |
77 | if (!page) {
78 | return notFound();
79 | }
80 |
81 | return {
82 | title: page.metadataOverrides.title ?? data.site.settings.metadata.defaultTitle,
83 | description:
84 | page.metadataOverrides.description ?? data.site.settings.metadata.defaultDescription,
85 | };
86 | };
87 |
88 | function SectionsUnion({
89 | comp,
90 | eventsKey,
91 | }: {
92 | comp: NonNullable["sections"]>[number];
93 | eventsKey: GeneralEvents["ingestKey"];
94 | }): React.ReactNode {
95 | switch (comp.__typename) {
96 | case "HeroComponent":
97 | return ;
98 | case "FeaturesCardsComponent":
99 | return ;
100 | case "FeaturesGridComponent":
101 | return ;
102 | case "CompaniesComponent":
103 | return ;
104 | case "FeaturesBigImageComponent":
105 | return ;
106 | case "FeaturesSideBySideComponent":
107 | return ;
108 | case "CalloutComponent":
109 | return ;
110 | case "CalloutV2Component":
111 | return ;
112 | case "TestimonialSliderComponent":
113 | return ;
114 | case "TestimonialsGridComponent":
115 | return ;
116 | case "PricingComponent":
117 | return ;
118 | case "FaqComponent":
119 | return ;
120 | case "FaqComponent":
121 | return ;
122 | case "PricingTableComponent":
123 | return ;
124 | case "FeatureHeroComponent":
125 | return ;
126 | case "FreeformTextComponent":
127 | return ;
128 | case "FormComponent":
129 | return ;
130 | default:
131 | return null;
132 | }
133 | }
134 |
135 | const sectionsFragment = fragmentOn("PagesItem", {
136 | sections: {
137 | __typename: true,
138 | on_BlockDocument: { _id: true, _slug: true },
139 | on_HeroComponent: heroFragment,
140 | on_FeaturesCardsComponent: featureCardsComponent,
141 | on_FeaturesSideBySideComponent: featuresSideBySideFragment,
142 | on_FeaturesBigImageComponent: bigFeatureFragment,
143 | on_FeaturesGridComponent: featuresGridFragment,
144 | on_CompaniesComponent: companiesFragment,
145 | on_CalloutComponent: calloutFragment,
146 | on_CalloutV2Component: calloutv2Fragment,
147 | on_TestimonialSliderComponent: testimonialsSliderFragment,
148 | on_TestimonialsGridComponent: testimonialsGridFragment,
149 | on_PricingComponent: pricingFragment,
150 | on_PricingTableComponent: pricingTableFragment,
151 | on_FeatureHeroComponent: featureHeroFragment,
152 | on_FaqComponent: {
153 | layout: true,
154 | ...faqFragment,
155 | },
156 | on_FreeformTextComponent: freeformTextFragment,
157 | on_FormComponent: formFragment,
158 | },
159 | });
160 |
161 | export default async function DynamicPage({
162 | params: _params,
163 | }: {
164 | params: Promise<{ slug?: string[] }>;
165 | }) {
166 | const params = await _params;
167 | const slugs = params.slug;
168 |
169 | return (
170 |
197 | {async ([
198 | {
199 | site: { pages, generalEvents },
200 | },
201 | ]) => {
202 | "use server";
203 |
204 | const page = pages.items[0];
205 |
206 | if (!page) notFound();
207 |
208 | const sections = page.sections;
209 |
210 | return (
211 | <>
212 |
213 | {sections?.map((section) => {
214 | return (
215 |
216 |
217 |
218 | );
219 | })}
220 | >
221 | );
222 | }}
223 |
224 | );
225 | }
226 |
--------------------------------------------------------------------------------
/src/app/_sections/pricing-comparation/index.tsx:
--------------------------------------------------------------------------------
1 | import { CheckCircledIcon, QuestionMarkCircledIcon } from "@radix-ui/react-icons";
2 | import * as React from "react";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 | import clsx from "clsx";
5 |
6 | import { Heading } from "@/common/heading";
7 | import { Section } from "@/common/layout";
8 | import { ButtonLink } from "@/common/button";
9 | import { type fragmentOn } from "basehub";
10 | import { SimpleTooltip } from "@/common/tooltip";
11 |
12 | import { MobilePricingComparison } from "./mobile-pricing-comparison";
13 | import { type planFragment, type pricingTableFragment, type valueFragment } from "./fragments";
14 |
15 | export type PricingTableProps = fragmentOn.infer;
16 |
17 | export function PricingTable(props: PricingTableProps) {
18 | const { heading, categories } = props;
19 | const plans = extractPlans(categories);
20 |
21 | return (
22 |
23 |
24 | {heading.title}
25 |
26 | {/* Desktop pricing */}
27 |
28 |
29 |
30 |
31 | {plans.map((plan) => (
32 |
33 | ))}
34 |
35 |
36 |
37 | {categories.items.map((category, i) => (
38 |
39 |
40 | {category.features.items.map((feature) => (
41 |
45 |
46 | {feature.values.items.map((value) => (
47 |
48 | ))}
49 |
50 | ))}
51 |
52 | ))}
53 |
54 |
55 |
56 |
57 | );
58 | }
59 |
60 | /* -------------------------------------------------------------------------- */
61 | /* Components */
62 | /* -------------------------------------------------------------------------- */
63 |
64 | /* ------------------------------- Generic cell ------------------------------- */
65 | const $tableCell = cva("min-h-16 px-3 text-base flex items-center gap-1.5 font-normal", {
66 | variants: {
67 | align: {
68 | start: "text-start justify-start",
69 | center: "text-center justify-center",
70 | end: "text-end justify-end",
71 | },
72 | type: {
73 | default: "text-text-secondary dark:text-dark-text-secondary",
74 | primary: "text-primary dark:text-dark-primary",
75 | },
76 | },
77 | defaultVariants: {
78 | align: "center",
79 | type: "default",
80 | },
81 | });
82 |
83 | interface TableCellProps {
84 | as?: T;
85 | className?: string;
86 | children: React.ReactNode;
87 | }
88 |
89 | function TableCell({
90 | as,
91 | className,
92 | children,
93 | align,
94 | type,
95 | ...props
96 | }: TableCellProps &
97 | React.ComponentPropsWithoutRef &
98 | VariantProps): React.JSX.Element {
99 | const Component = as ?? "div";
100 |
101 | return (
102 |
103 | {children}
104 |
105 | );
106 | }
107 |
108 | /* ------------------------------ Feature Title ----------------------------- */
109 |
110 | function FeatureTitle(
111 | feature: PricingTableProps["categories"]["items"][0]["features"]["items"][0],
112 | ) {
113 | return (
114 |
115 |
116 | {feature._title}
117 | {feature.tooltip ? (
118 |
124 |
125 |
126 | ) : null}
127 |
128 | |
129 | );
130 | }
131 |
132 | /* ------------------------------ Category header ---------------------------- */
133 |
134 | function CategoryHeader({
135 | category,
136 | className,
137 | }: {
138 | category: PricingTableProps["categories"]["items"][0];
139 | className?: string;
140 | }) {
141 | return (
142 |
143 | |
144 |
150 | {category._title}
151 |
152 | |
153 | {Array.from(category.features.items[0]?.values.items ?? []).map((_) => (
154 | |
155 | ))}
156 |
157 | );
158 | }
159 |
160 | /* --------------------------------- Plan Header --------------------------------- */
161 |
162 | type ValueFragment = fragmentOn.infer;
163 |
164 | function PlanHeader({ plan }: { plan: PlanFragment | null }) {
165 | return plan ? (
166 |
167 |
168 |
169 |
170 | {plan._title}
171 |
172 | {plan.price}
173 |
174 |
175 | Get started
176 |
177 |
178 | |
179 | ) : (
180 | |
181 | );
182 | }
183 |
184 | /* --------------------------------- Cell td (value) -------------------------------- */
185 |
186 | function FeatureValue({ value }: { value?: ValueFragment }) {
187 | return (
188 |
189 |
190 | {value ? (
191 | value.value?.__typename === "BooleanComponent" ? (
192 | value.value.boolean ? (
193 |
194 |
195 |
196 | ) : (
197 |
198 | —
199 |
200 | )
201 | ) : value.value?.__typename === "CustomTextComponent" ? (
202 | {value.value.text}
203 | ) : null
204 | ) : null}
205 |
206 | |
207 | );
208 | }
209 |
210 | /* -------------------------------------------------------------------------- */
211 | /* Utils */
212 | /* -------------------------------------------------------------------------- */
213 |
214 | export type PlanFragment = fragmentOn.infer;
215 |
216 | const extractPlans = (categories: PricingTableProps["categories"]) => {
217 | const plans = new Map();
218 |
219 | categories.items.forEach((category) => {
220 | category.features.items.forEach((feature) => {
221 | feature.values.items.forEach((value) => {
222 | plans.set(value.plan._title, value.plan);
223 | });
224 | });
225 | });
226 |
227 | return Array.from(plans.values());
228 | };
229 |
--------------------------------------------------------------------------------
/src/app/_components/rich-text/index.tsx:
--------------------------------------------------------------------------------
1 | import { RichText, type RichTextProps } from "basehub/react-rich-text";
2 | import { ChevronDownIcon } from "@radix-ui/react-icons";
3 | import { cva } from "class-variance-authority";
4 |
5 | import { fragmentOn } from "basehub";
6 | import s from "./rich-text.module.scss";
7 | import Image from "next/image";
8 | import clsx from "clsx";
9 |
10 | export const richTextClasses = clsx(
11 | "prose prose-zinc max-w-prose text-start dark:prose-invert font-normal text-md w-full leading-relaxed",
12 | "prose-p:text-text-secondary dark:prose-p:text-dark-text-secondary",
13 | "prose-h1:text-4xl prose-h1:font-medium prose-h1:text-text-primary dark:prose-h1:text-dark-text-primary",
14 | "prose-h2:text-3xl prose-h2:font-medium prose-h2:text-text-primary dark:prose-h2:text-dark-text-primary",
15 | "prose-h3:text-2xl prose-h3:font-medium prose-h3:text-text-primary dark:prose-h3:text-dark-text-primary",
16 | "prose-blockquote:border-border prose-blockquote:pl-5 prose-blockquote:text-2xl prose-blockquote:text-text-primary dark:prose-blockquote:border-dark-border dark:prose-blockquote:text-dark-text-primary",
17 | '[&_blockquote>p]:before:[content:""] [&_blockquote>p]:prose-blockquote:after:[content:""]',
18 | "prose-h4:text-2xl prose-h4:font-medium",
19 | "prose-strong:font-medium",
20 | "prose-a:outline-accent-500 dark:prose-a:text-accent-400 prose-a:text-accent-600 prose-a:no-underline prose-a:hover:underline prose-a:decoration-accent-500/50",
21 | "prose-pre:pl-0",
22 | s["rich-text"],
23 | );
24 |
25 | // @ts-expect-error Code won't match props
26 | export const richTextBaseComponents: RichTextProps["components"] = {
27 | code: Code,
28 | pre: ({ children }) => <>{children}>,
29 | b: ({ children }) => {children},
30 | img: (props) => ,
31 | video: (props) => ,
32 | };
33 |
34 | function Code({
35 | children,
36 | isInline,
37 | code,
38 | }: {
39 | children: React.ReactNode;
40 | isInline: boolean;
41 | code: string;
42 | }) {
43 | if (isInline) {
44 | return (
45 |
46 | {children}
47 |
48 | );
49 | } else
50 | return {code};
51 | }
52 |
53 | export const FaqItemComponentFragment = fragmentOn("FaqItemComponent", {
54 | _id: true,
55 | _idPath: true,
56 | _title: true,
57 | answer: true,
58 | });
59 |
60 | type FaqItemComponentRichText = fragmentOn.infer;
61 |
62 | export function FaqRichtextComponent({ answer, _title }: FaqItemComponentRichText) {
63 | return (
64 |
65 |
66 |
67 |
68 |
69 | {_title}
70 |
71 | {answer}
72 |
73 | );
74 | }
75 |
76 | export const richTextCalloutComponentFragment = fragmentOn("RichTextCalloutComponent", {
77 | _title: true,
78 | type: true,
79 | _id: true,
80 | size: true,
81 | content: {
82 | json: {
83 | content: true,
84 | },
85 | },
86 | __typename: true,
87 | _idPath: true,
88 | });
89 |
90 | type RichTextCalloutComponentFragment = fragmentOn.infer;
91 |
92 | const $richTextCallout = cva(
93 | "gap-2 border border-accent-500/40 bg-accent-500/5 p-4 pl-3 rounded-xl",
94 | {
95 | variants: {
96 | size: {
97 | large: "flex flex-col",
98 | default: "flex flex-row",
99 | },
100 | },
101 | defaultVariants: {
102 | size: "default",
103 | },
104 | },
105 | );
106 |
107 | export function RichTextCalloutComponent({
108 | _title,
109 | size,
110 | content,
111 | }: RichTextCalloutComponentFragment) {
112 | switch (size) {
113 | case "large":
114 | return (
115 |
116 |
117 | {content?.json.content}
118 |
119 |
120 | );
121 | default:
122 | return (
123 |
124 |
138 |
139 | {content?.json.content}
140 |
141 |
142 | );
143 | }
144 | }
145 |
146 | export function RichTextImage(props: {
147 | src: string;
148 | alt?: string;
149 | width?: number;
150 | height?: number;
151 | caption?: string;
152 | }) {
153 | return (
154 |
155 |
156 | {props.caption ? (
157 |
158 | {props.caption}
159 |
160 | ) : null}
161 |
162 | );
163 | }
164 |
165 | export function RichTextVideo(props: {
166 | src: string;
167 | alt?: string;
168 | width?: number;
169 | height?: number;
170 | caption?: string;
171 | }) {
172 | return (
173 |
174 |
178 | {props.caption ? (
179 |
180 | {props.caption}
181 |
182 | ) : null}
183 |
184 | );
185 | }
186 |
--------------------------------------------------------------------------------
/src/common/search/index.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | import { useSearch, SearchBox, type Hit } from "basehub/react-search";
4 | import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
5 | import NextLink from "next/link";
6 | import clsx from "clsx";
7 | import * as Popover from "@radix-ui/react-popover";
8 |
9 | import { useSearchHits } from "@/context/search-hits-context";
10 | import { type AuthorFragment } from "@/lib/basehub/fragments";
11 | import { getArticleSlugFromSlugPath } from "@/lib/basehub/utils";
12 |
13 | import { Avatar } from "../avatar";
14 | import { AvatarsGroup } from "../avatars-group";
15 |
16 | export function SearchContent({ _searchKey }: { _searchKey: string }) {
17 | const search = useSearch({
18 | _searchKey,
19 | queryBy: ["_title", "body", "description", "categories", "authors"],
20 | limit: 20,
21 | });
22 |
23 | const [open, setOpen] = React.useState(false);
24 | const searchInputRef = React.useRef(null);
25 |
26 | React.useEffect(() => {
27 | if (search.query) setOpen(true);
28 | else setOpen(false);
29 | }, [search.query]);
30 |
31 | React.useEffect(() => {
32 | const handleKeyDown = (event: KeyboardEvent) => {
33 | if (event.key === "k" && event.metaKey) {
34 | event.preventDefault();
35 | searchInputRef.current?.blur();
36 | searchInputRef.current?.focus();
37 | }
38 | };
39 |
40 | document.addEventListener("keydown", handleKeyDown);
41 |
42 | return () => {
43 | document.removeEventListener("keydown", handleKeyDown);
44 | };
45 | }, []);
46 |
47 | return (
48 |
49 |
50 |
51 | {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
52 |
75 |
76 |
77 |
78 | {
84 | e.preventDefault();
85 | }}
86 | >
87 |
88 |
89 |
90 | No results for “{search.query}”
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 | );
107 | }
108 |
109 | function HitList({ hits }: { hits: Hit[] }) {
110 | return (
111 |
112 | {hits.map((hit) => {
113 | const pathname = getArticleSlugFromSlugPath(hit.document._slugPath ?? "");
114 |
115 | const field = hit._getField("authors");
116 | let firstHighlightedAuthorId: string | undefined = undefined;
117 |
118 | for (const h of hit.highlights) {
119 | if (h.fieldPath.startsWith("authors")) {
120 | const index = h.fieldPath.split(".")[1];
121 |
122 | if (!index) continue;
123 | const id = hit._getField(`authors.${index}._id`);
124 |
125 | if (typeof id === "string") {
126 | firstHighlightedAuthorId = id;
127 | }
128 | break;
129 | }
130 | }
131 |
132 | return (
133 |
134 |
135 |
144 |
150 |
157 |
158 |
162 |
168 |
169 |
170 |
171 |
172 | );
173 | })}
174 |
175 | );
176 | }
177 |
178 | function HitTitleContainer({ children }: React.PropsWithChildren) {
179 | return (
180 |
181 | {children}
182 |
183 | );
184 | }
185 |
186 | function HitBodyContainer({ children }: React.PropsWithChildren) {
187 | return (
188 | {children}
189 | );
190 | }
191 |
192 | function CustomAvatarHit({
193 | match,
194 | authors,
195 | }: {
196 | match: string | undefined;
197 | authors: { _title: string; _id: string }[];
198 | }) {
199 | const { authorsAvatars } = useSearchHits();
200 |
201 | if (match) {
202 | const author = authorsAvatars[match];
203 |
204 | if (!author) return null;
205 |
206 | return (
207 |
216 | );
217 | }
218 |
219 | return (
220 |
221 | {authors.map((author) => {
222 | const avatar = authorsAvatars[author._id];
223 |
224 | if (!avatar) return null;
225 |
226 | return ;
227 | })}
228 |
229 | );
230 | }
231 |
232 | function HitContainer({ children }: React.PropsWithChildren) {
233 | return {children}
;
234 | }
235 |
--------------------------------------------------------------------------------