mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 03:45:06 +03:00
/pricing and /blog invisible to LLMs — fix SSR bailout (#45292)
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? push client directive to leaf interactive components, keep content in server shell ## What is the current behavior? /pricing and blog pages are rendered it client side so crawlers don't get any content on fetch. Verifiy content with ```bash curl -s http://localhost:3000/pricing | sed 's/<script[^>]*>.*<\/script>//g; s/<style[^>]*>.*<\/style>//g; s/<[^>]*>//g' | wc -w curl -s http://localhost:3000/blog | sed 's/<script[^>]*>.*<\/script>//g; s/<style[^>]*>.*<\/style>//g; s/<[^>]*>//g' | wc -w curl -s http://localhost:3000/blog/<some-slug> | sed 's/<script[^>]*>.*<\/script>//g; s/<style[^>]*>.*<\/style>//g; s/<[^>]*>//g' | wc -w ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Featured blog post now displays prominently on the blog landing page * Added smooth anchor link navigation on the pricing page * Improved table of contents tracking with scroll-to-anchor behavior on blog posts * Enhanced pricing comparison and plans sections with better data integration <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
96939829bc
commit
939b4034d9
11 files changed
+249
-205
No files matched your search
@@ -1,16 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import DefaultLayout from 'components/Layouts/Default'
|
||||
import { isBrowser, LOCAL_STORAGE_KEYS } from 'common'
|
||||
import BlogFilters from 'components/Blog/BlogFilters'
|
||||
import BlogGridItem from 'components/Blog/BlogGridItem'
|
||||
import BlogListItem from 'components/Blog/BlogListItem'
|
||||
import BlogFilters from 'components/Blog/BlogFilters'
|
||||
import FeaturedThumb from 'components/Blog/FeaturedThumb'
|
||||
import { cn } from 'ui'
|
||||
import { LOCAL_STORAGE_KEYS, isBrowser } from 'common'
|
||||
import { useInfiniteScrollWithFetch } from 'hooks/useInfiniteScroll'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import type PostTypes from 'types/post'
|
||||
import { cn } from 'ui'
|
||||
|
||||
export type BlogView = 'list' | 'grid'
|
||||
|
||||
@@ -74,14 +71,20 @@ export default function BlogClient({ initialBlogs, totalPosts }: BlogClientProps
|
||||
const [filteredTotal, setFilteredTotal] = useState<number | null>(null)
|
||||
const isList = view === 'list'
|
||||
|
||||
// Determine which posts and total to use based on filter state
|
||||
const currentPosts = filteredPosts ?? initialBlogs
|
||||
const currentTotal = filteredTotal ?? totalPosts
|
||||
|
||||
const fetchMorePosts = useCallback(
|
||||
async (offset: number, limit: number) => {
|
||||
const isFiltered =
|
||||
(filterParams.category && filterParams.category !== 'all') || Boolean(filterParams.search)
|
||||
// The featured post is rendered above the list (not in `items`), so the
|
||||
// API offset has to skip past it for unfiltered fetches. Filtered
|
||||
// results come from a separate query and don't share that hero slot.
|
||||
const apiOffset = isFiltered ? offset : offset + 1
|
||||
|
||||
const params = new URLSearchParams({
|
||||
offset: offset.toString(),
|
||||
offset: apiOffset.toString(),
|
||||
limit: limit.toString(),
|
||||
})
|
||||
|
||||
@@ -118,9 +121,7 @@ export default function BlogClient({ initialBlogs, totalPosts }: BlogClientProps
|
||||
rootMargin: '1000px',
|
||||
})
|
||||
|
||||
// Handle filter changes - fetch filtered results from API
|
||||
const handleFilterChange = useCallback(async (category?: string, search?: string) => {
|
||||
// If no filters, reset to initial state
|
||||
if ((!category || category === 'all') && !search) {
|
||||
setFilterParams({})
|
||||
setFilteredPosts(null)
|
||||
@@ -159,78 +160,64 @@ export default function BlogClient({ initialBlogs, totalPosts }: BlogClientProps
|
||||
}
|
||||
}, [])
|
||||
|
||||
const featuredPost = initialBlogs[0]
|
||||
|
||||
return (
|
||||
<>
|
||||
<DefaultLayout>
|
||||
<h1 className="sr-only">Supabase blog</h1>
|
||||
<div className="container relative mx-auto px-4 py-4 md:py-8 xl:py-10 sm:px-16 xl:px-20">
|
||||
{featuredPost && <FeaturedThumb key={featuredPost.slug} {...featuredPost} />}
|
||||
</div>
|
||||
<BlogFilters onFilterChange={handleFilterChange} view={view} setView={setView} />
|
||||
|
||||
<div className="border-default border-t">
|
||||
<div className="container mx-auto px-4 py-4 md:py-8 xl:py-10 sm:px-16 xl:px-20">
|
||||
<BlogFilters onFilterChange={handleFilterChange} view={view} setView={setView} />
|
||||
|
||||
<ol
|
||||
className={cn(
|
||||
'grid -mx-2 sm:-mx-4 py-6 lg:py-6 lg:pb-20',
|
||||
isList ? 'grid-cols-1' : 'grid-cols-12 lg:gap-4'
|
||||
)}
|
||||
>
|
||||
{isFiltering ? (
|
||||
// Show skeletons while filtering
|
||||
isList ? (
|
||||
Array.from({ length: SKELETON_COUNT }).map((_, idx) => (
|
||||
<div
|
||||
className="col-span-12 px-2 sm:px-4 [&_a]:last:border-none"
|
||||
key={`skeleton-list-${idx}`}
|
||||
>
|
||||
<BlogListItemSkeleton />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
Array.from({ length: SKELETON_COUNT }).map((_, idx) => (
|
||||
<div
|
||||
className="col-span-12 mb-4 md:col-span-12 lg:col-span-6 xl:col-span-4 h-full"
|
||||
key={`skeleton-grid-${idx}`}
|
||||
>
|
||||
<BlogGridItemSkeleton />
|
||||
</div>
|
||||
))
|
||||
)
|
||||
) : blogs?.length ? (
|
||||
blogs?.map((blog: PostTypes, idx: number) =>
|
||||
isList ? (
|
||||
<div
|
||||
className="col-span-12 px-2 sm:px-4 [&_a]:last:border-none"
|
||||
key={`list-${idx}-${blog.slug}`}
|
||||
>
|
||||
<BlogListItem post={blog} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="col-span-12 mb-4 md:col-span-12 lg:col-span-6 xl:col-span-4 h-full"
|
||||
key={`grid-${idx}-${blog.slug}`}
|
||||
>
|
||||
<BlogGridItem post={blog} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-light col-span-full">No results</p>
|
||||
)}
|
||||
</ol>
|
||||
|
||||
{hasMore && !isFiltering && (
|
||||
<div ref={loadMoreRef} className="flex justify-center py-8" aria-hidden="true">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-foreground-muted border-t-foreground" />
|
||||
<ol
|
||||
className={cn(
|
||||
'grid -mx-2 sm:-mx-4 py-6 lg:py-6 lg:pb-20',
|
||||
isList ? 'grid-cols-1' : 'grid-cols-12 lg:gap-4'
|
||||
)}
|
||||
>
|
||||
{isFiltering ? (
|
||||
isList ? (
|
||||
Array.from({ length: SKELETON_COUNT }).map((_, idx) => (
|
||||
<div
|
||||
className="col-span-12 px-2 sm:px-4 [&_a]:last:border-none"
|
||||
key={`skeleton-list-${idx}`}
|
||||
>
|
||||
<BlogListItemSkeleton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
Array.from({ length: SKELETON_COUNT }).map((_, idx) => (
|
||||
<div
|
||||
className="col-span-12 mb-4 md:col-span-12 lg:col-span-6 xl:col-span-4 h-full"
|
||||
key={`skeleton-grid-${idx}`}
|
||||
>
|
||||
<BlogGridItemSkeleton />
|
||||
</div>
|
||||
))
|
||||
)
|
||||
) : blogs?.length ? (
|
||||
blogs?.map((blog: PostTypes, idx: number) =>
|
||||
isList ? (
|
||||
<div
|
||||
className="col-span-12 px-2 sm:px-4 [&_a]:last:border-none"
|
||||
key={`list-${idx}-${blog.slug}`}
|
||||
>
|
||||
<BlogListItem post={blog} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="col-span-12 mb-4 md:col-span-12 lg:col-span-6 xl:col-span-4 h-full"
|
||||
key={`grid-${idx}-${blog.slug}`}
|
||||
>
|
||||
<BlogGridItem post={blog} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-light col-span-full">No results</p>
|
||||
)}
|
||||
</ol>
|
||||
|
||||
{hasMore && !isFiltering && (
|
||||
<div ref={loadMoreRef} className="flex justify-center py-8" aria-hidden="true">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-foreground-muted border-t-foreground" />
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import useActiveAnchors from '@/hooks/useActiveAnchors'
|
||||
|
||||
// Mounts the scroll-to-anchor + TOC active-section tracking listeners.
|
||||
// Lives in its own client island so the surrounding blog post markup can SSR.
|
||||
export default function BlogPostAnchorEffect() {
|
||||
useActiveAnchors()
|
||||
return null
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
import useActiveAnchors from '@/hooks/useActiveAnchors'
|
||||
import BlogPostAnchorEffect from './BlogPostAnchorEffect'
|
||||
import BlogPostRenderer from '@/components/Blog/BlogPostRenderer'
|
||||
import authors from '@/lib/authors.json'
|
||||
import { isNotNullOrUndefined } from '@/lib/helpers'
|
||||
import type { Blog, BlogData, PostReturnType, ProcessedBlogData } from '@/types/post'
|
||||
|
||||
const BlogPostRenderer = dynamic(() => import('@/components/Blog/BlogPostRenderer'))
|
||||
|
||||
type BlogPostPageProps = {
|
||||
prevPost: PostReturnType | null
|
||||
nextPost: PostReturnType | null
|
||||
@@ -17,14 +12,7 @@ type BlogPostPageProps = {
|
||||
isDraftMode: boolean
|
||||
}
|
||||
|
||||
// Note: convertRichTextToMarkdown is now imported from the shared utility
|
||||
|
||||
export default function BlogPostClient(props: BlogPostPageProps) {
|
||||
const isDraftMode = props.isDraftMode
|
||||
|
||||
// Enable scroll-to-anchor functionality and TOC highlighting
|
||||
useActiveAnchors()
|
||||
|
||||
const blogMetaData = props.blog
|
||||
|
||||
const blogAuthors = (blogMetaData.author ?? '')
|
||||
@@ -38,13 +26,16 @@ export default function BlogPostClient(props: BlogPostPageProps) {
|
||||
.filter(isNotNullOrUndefined)
|
||||
|
||||
return (
|
||||
<BlogPostRenderer
|
||||
blog={props.blog as ProcessedBlogData}
|
||||
blogMetaData={blogMetaData as ProcessedBlogData}
|
||||
isDraftMode={isDraftMode}
|
||||
prevPost={props.prevPost}
|
||||
nextPost={props.nextPost}
|
||||
authors={blogAuthors}
|
||||
/>
|
||||
<>
|
||||
<BlogPostAnchorEffect />
|
||||
<BlogPostRenderer
|
||||
blog={props.blog as ProcessedBlogData}
|
||||
blogMetaData={blogMetaData as ProcessedBlogData}
|
||||
isDraftMode={props.isDraftMode}
|
||||
prevPost={props.prevPost}
|
||||
nextPost={props.nextPost}
|
||||
authors={blogAuthors}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import FeaturedThumb from 'components/Blog/FeaturedThumb'
|
||||
import DefaultLayout from 'components/Layouts/Default'
|
||||
import type { Metadata } from 'next'
|
||||
import type PostTypes from 'types/post'
|
||||
|
||||
import BlogClient from './BlogClient'
|
||||
import { getSortedPosts } from '@/lib/posts'
|
||||
@@ -19,19 +22,34 @@ export const metadata: Metadata = {
|
||||
const INITIAL_POSTS_LIMIT = 25
|
||||
|
||||
export default async function BlogPage() {
|
||||
// Get static blog posts
|
||||
const staticPostsData = getSortedPosts({ directory: '_blog', runner: '** BLOG PAGE **' })
|
||||
|
||||
// Sort by date
|
||||
const allPosts = [...staticPostsData].sort((a, b) => {
|
||||
const dateA = new Date(a.date || a.formattedDate).getTime()
|
||||
const dateB = new Date(b.date || b.formattedDate).getTime()
|
||||
return dateB - dateA
|
||||
})
|
||||
|
||||
// Only send initial posts to client, rest will be loaded via API
|
||||
const initialPosts = allPosts.slice(0, INITIAL_POSTS_LIMIT)
|
||||
const totalPosts = allPosts.length
|
||||
const featuredPost = initialPosts[0]
|
||||
// Featured post is rendered as the hero above the list, so exclude it from
|
||||
// the list to avoid showing it twice. BlogClient compensates the API offset
|
||||
// when scrolling for more posts.
|
||||
const listPosts = initialPosts.slice(1)
|
||||
const totalListPosts = Math.max(0, allPosts.length - 1)
|
||||
|
||||
return <BlogClient initialBlogs={initialPosts} totalPosts={totalPosts} />
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<h1 className="sr-only">Supabase blog</h1>
|
||||
<div className="container relative mx-auto px-4 py-4 md:py-8 xl:py-10 sm:px-16 xl:px-20">
|
||||
{featuredPost && <FeaturedThumb key={featuredPost.slug} {...(featuredPost as PostTypes)} />}
|
||||
</div>
|
||||
|
||||
<div className="border-default border-t">
|
||||
<div className="container mx-auto px-4 py-4 md:py-8 xl:py-10 sm:px-16 xl:px-20">
|
||||
<BlogClient initialBlogs={listPosts} totalPosts={totalListPosts} />
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
// Scrolls to the section matching window.location.hash on mount and on
|
||||
// pathname change. We render a mobile and a desktop row for each item, so the
|
||||
// id-based anchors are suffixed with `-mobile` / `-desktop` and we pick the
|
||||
// right one based on viewport width.
|
||||
export default function HashAnchorScroll() {
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.slice(1)
|
||||
if (!hash) return
|
||||
|
||||
let device = 'desktop'
|
||||
if (window.matchMedia('screen and (max-width: 1024px)').matches) {
|
||||
device = 'mobile'
|
||||
}
|
||||
|
||||
const element = document.querySelector(`#${hash}-${device}`)
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import PricingComparisonTable from '~/components/Pricing/PricingComparisonTable'
|
||||
import { useOrganizations } from '~/data/organizations'
|
||||
|
||||
export default function PricingComparisonSection() {
|
||||
const { isLoading, organizations } = useOrganizations()
|
||||
const hasExistingOrganizations = !isLoading && organizations.length > 0
|
||||
|
||||
return (
|
||||
<PricingComparisonTable
|
||||
organizations={organizations}
|
||||
hasExistingOrganizations={hasExistingOrganizations}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { ArrowDownIcon } from '@heroicons/react/outline'
|
||||
import CTABanner from '~/components/CTABanner'
|
||||
import DefaultLayout from '~/components/Layouts/Default'
|
||||
import PricingPlans from '~/components/Pricing/PricingPlans'
|
||||
import { useOrganizations } from '~/data/organizations'
|
||||
import { hasConsented, posthogClient } from 'common'
|
||||
import PricingAddons from '~/components/Pricing/PricingAddons'
|
||||
import PricingComputeSection from '~/components/Pricing/PricingComputeSection'
|
||||
import PricingDiskSection from '~/components/Pricing/PricingDiskSection'
|
||||
import PricingFAQs from '~/components/Pricing/PricingFAQs'
|
||||
import { ArrowUpRight } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
|
||||
const EXPERIMENT_ID = 'pricingPageExperiment' as const
|
||||
import HashAnchorScroll from './HashAnchorScroll'
|
||||
import PricingComparisonSection from './PricingComparisonSection'
|
||||
import PricingPlansSection from './PricingPlansSection'
|
||||
|
||||
export type PricingPageExperimentVariant =
|
||||
| 'control'
|
||||
// GROWTH-694: flexibility/hourly billing
|
||||
@@ -23,77 +23,10 @@ export type PricingPageExperimentVariant =
|
||||
| 'multi_project' // "first project included, additional from $10/mo" on Pro card
|
||||
| 'estimate_cta' // "estimate your cost" link on Pro card scrolling to calculator
|
||||
|
||||
const PricingComputeSection = dynamic(() => import('~/components/Pricing/PricingComputeSection'))
|
||||
const PricingAddons = dynamic(() => import('~/components/Pricing/PricingAddons'))
|
||||
const PricingComparisonTable = dynamic(() => import('~/components/Pricing/PricingComparisonTable'))
|
||||
const PricingFAQs = dynamic(() => import('~/components/Pricing/PricingFAQs'))
|
||||
const CTABanner = dynamic(() => import('~/components/CTABanner'))
|
||||
const PricingDiskSection = dynamic(() => import('~/components/Pricing/PricingDiskSection'))
|
||||
|
||||
export default function PricingContent() {
|
||||
const pathname = usePathname()
|
||||
|
||||
// Ability to scroll into pricing sections like storage
|
||||
useEffect(() => {
|
||||
/**
|
||||
* As we render a mobile and a desktop row for each item and just display based on screen size, we cannot navigate by simple id hash
|
||||
* on both mobile and desktop. To handle both cases, we actually need to check screen size
|
||||
*/
|
||||
|
||||
const hash = window.location.hash.slice(1)
|
||||
if (!hash) return
|
||||
|
||||
let device = 'desktop'
|
||||
if (window.matchMedia('screen and (max-width: 1024px)').matches) {
|
||||
device = 'mobile'
|
||||
}
|
||||
|
||||
const element = document.querySelector(`#${hash}-${device}`)
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
const { isLoading, organizations } = useOrganizations()
|
||||
const hasExistingOrganizations = !isLoading && organizations.length > 0
|
||||
|
||||
// Pricing value/flexibility A/B experiment
|
||||
// Uses client-side PostHog directly — server-side evaluation lacks full person context for www pages.
|
||||
// DevToolbar overrides (x-ph-flag-overrides cookie) are respected in local dev via posthogClient.getFeatureFlag.
|
||||
const [flagValue, setFlagValue] = useState<PricingPageExperimentVariant | false | undefined>(
|
||||
() =>
|
||||
posthogClient.getFeatureFlag(EXPERIMENT_ID) as
|
||||
| PricingPageExperimentVariant
|
||||
| false
|
||||
| undefined
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return posthogClient.onFeatureFlags(() => {
|
||||
const value = posthogClient.getFeatureFlag(EXPERIMENT_ID)
|
||||
setFlagValue(value as PricingPageExperimentVariant | false | undefined)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const validVariants: PricingPageExperimentVariant[] = [
|
||||
'control',
|
||||
'flexibility',
|
||||
'flexibility_card',
|
||||
'hourly_rate',
|
||||
'multi_project',
|
||||
'estimate_cta',
|
||||
]
|
||||
const isInExperiment = validVariants.includes(flagValue as PricingPageExperimentVariant)
|
||||
const showFlexibilitySection = flagValue === 'flexibility'
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInExperiment) return
|
||||
|
||||
posthogClient.captureExperimentExposure(EXPERIMENT_ID, { variant: flagValue }, hasConsented())
|
||||
}, [isInExperiment, flagValue])
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<HashAnchorScroll />
|
||||
<div className="relative z-10 pt-8 pb-4 xl:py-16">
|
||||
<div className="mx-auto max-w-7xl px-8 text-center sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-3xl space-y-2 lg:max-w-none">
|
||||
@@ -108,22 +41,7 @@ export default function PricingContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PricingPlans
|
||||
organizations={organizations}
|
||||
hasExistingOrganizations={hasExistingOrganizations}
|
||||
experimentVariant={isInExperiment ? (flagValue as PricingPageExperimentVariant) : undefined}
|
||||
/>
|
||||
|
||||
{showFlexibilitySection && (
|
||||
<div className="mx-auto max-w-xl px-8 mt-10 xl:mt-16 text-center">
|
||||
<p className="text-foreground text-sm">
|
||||
Need more power for a launch? Scale up instantly.{' '}
|
||||
<span className="text-foreground-light">
|
||||
Scale back down after — you only pay for the hours you use.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<PricingPlansSection />
|
||||
|
||||
<div className="text-center mt-10 xl:mt-16 mx-auto max-w-lg flex flex-col gap-8">
|
||||
<div className="flex justify-center gap-2">
|
||||
@@ -179,10 +97,7 @@ export default function PricingContent() {
|
||||
<PricingAddons />
|
||||
</div>
|
||||
|
||||
<PricingComparisonTable
|
||||
organizations={organizations}
|
||||
hasExistingOrganizations={hasExistingOrganizations}
|
||||
/>
|
||||
<PricingComparisonSection />
|
||||
|
||||
<div id="faq" className="border-t">
|
||||
<PricingFAQs />
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import PricingPlans from '~/components/Pricing/PricingPlans'
|
||||
import { useOrganizations } from '~/data/organizations'
|
||||
import { hasConsented, posthogClient } from 'common'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { PricingPageExperimentVariant } from './PricingContent'
|
||||
|
||||
const EXPERIMENT_ID = 'pricingPageExperiment' as const
|
||||
|
||||
const VALID_VARIANTS: PricingPageExperimentVariant[] = [
|
||||
'control',
|
||||
'flexibility',
|
||||
'flexibility_card',
|
||||
'hourly_rate',
|
||||
'multi_project',
|
||||
'estimate_cta',
|
||||
]
|
||||
|
||||
export default function PricingPlansSection() {
|
||||
const { isLoading, organizations } = useOrganizations()
|
||||
const hasExistingOrganizations = !isLoading && organizations.length > 0
|
||||
|
||||
// Pricing value/flexibility A/B experiment.
|
||||
// Uses client-side PostHog directly — server-side evaluation lacks full person context for www pages.
|
||||
// DevToolbar overrides (x-ph-flag-overrides cookie) are respected in local dev via posthogClient.getFeatureFlag.
|
||||
const [flagValue, setFlagValue] = useState<PricingPageExperimentVariant | false | undefined>(
|
||||
() =>
|
||||
posthogClient.getFeatureFlag(EXPERIMENT_ID) as
|
||||
| PricingPageExperimentVariant
|
||||
| false
|
||||
| undefined
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return posthogClient.onFeatureFlags(() => {
|
||||
const value = posthogClient.getFeatureFlag(EXPERIMENT_ID)
|
||||
setFlagValue(value as PricingPageExperimentVariant | false | undefined)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isInExperiment = VALID_VARIANTS.includes(flagValue as PricingPageExperimentVariant)
|
||||
const showFlexibilitySection = flagValue === 'flexibility'
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInExperiment) return
|
||||
|
||||
posthogClient.captureExperimentExposure(EXPERIMENT_ID, { variant: flagValue }, hasConsented())
|
||||
}, [isInExperiment, flagValue])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PricingPlans
|
||||
organizations={organizations}
|
||||
hasExistingOrganizations={hasExistingOrganizations}
|
||||
experimentVariant={isInExperiment ? (flagValue as PricingPageExperimentVariant) : undefined}
|
||||
/>
|
||||
|
||||
{showFlexibilitySection && (
|
||||
<div className="mx-auto max-w-xl px-8 mt-10 xl:mt-16 text-center">
|
||||
<p className="text-foreground text-sm">
|
||||
Need more power for a launch? Scale up instantly.{' '}
|
||||
<span className="text-foreground-light">
|
||||
Scale back down after — you only pay for the hours you use.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { PropsWithChildren, useEffect, useRef } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { cn } from 'ui'
|
||||
'use client'
|
||||
|
||||
import { detectBrowser, isBrowser } from 'common'
|
||||
import { motion } from 'framer-motion'
|
||||
import React, { PropsWithChildren, useEffect, useRef } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
interface Props {
|
||||
outerClassName?: string
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronDownIcon } from '@heroicons/react/outline'
|
||||
import Link from 'next/link'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import pricingFaq from '~/data/PricingFAQ.json'
|
||||
import React from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
Reference in new issue
Block a user