diff --git a/apps/www/app/state-of-startups/StateOfStartups2026Content.tsx b/apps/www/app/state-of-startups/StateOfStartups2026Content.tsx new file mode 100644 index 00000000000..a512af65ba0 --- /dev/null +++ b/apps/www/app/state-of-startups/StateOfStartups2026Content.tsx @@ -0,0 +1,356 @@ +'use client' + +import DefaultLayout from '~/components/Layouts/Default' +import pageData from '~/data/surveys/state-of-startups-2026' +import { useSendTelemetryEvent } from '~/lib/telemetry' +import { AnimatePresence, motion } from 'framer-motion' +import Link from 'next/link' +import { forwardRef, useEffect, useRef, useState, type RefObject } from 'react' +import { Button, cn } from 'ui' + +import { StateOfStartupsAuroraHeader } from './components/StateOfStartupsAuroraHeader' +import { SurveyChapter } from './components/SurveyChapter' +import { SurveyChapterSection } from './components/SurveyChapterSection' +import { SurveySectionBreak } from './components/SurveySectionBreak' + +interface FloatingTocProps { + tocRef: RefObject + isTocOpen: boolean + setIsTocOpen: (open: boolean) => void + activeChapter: number +} + +function FloatingTableOfContents({ + tocRef, + isTocOpen, + setIsTocOpen, + activeChapter, +}: FloatingTocProps) { + const currentChapter = pageData.pageChapters[activeChapter - 1] + + return ( + +
+ + {!isTocOpen ? ( + setIsTocOpen(true)} + className="flex flex-row items-center gap-2 shadow-xl rounded-full overflow-hidden px-2 pr-4 h-[30px] border border-overlay bg-overlay text-foreground font-mono uppercase text-xs" + initial={{ opacity: 0, scale: 0.9, y: -6 }} + animate={{ opacity: 1, scale: 1, y: 0 }} + exit={{ opacity: 0, scale: 0.9, y: -6 }} + transition={{ + layout: { duration: 0.5, type: 'spring' }, + duration: 0.15, + ease: 'easeOut', + }} + > + + {activeChapter} + + + + {currentChapter?.shortTitle} + + + + ) : ( + +
    + {pageData.pageChapters.map((chapter, chapterIndex) => ( + + setIsTocOpen(false)} + className={cn( + 'block px-6 py-2 text-xs transition-colors font-mono uppercase tracking-wider text-center text-foreground-light hover:text-brand-link hover:bg-brand-300/25', + chapterIndex + 1 === activeChapter && + 'bg-brand-300/40 text-brand-link dark:text-brand' + )} + > + {chapter.shortTitle} + + + ))} +
+
+ )} +
+
+
+ ) +} + +export default function StateOfStartups2026Content() { + const [showFloatingToc, setShowFloatingToc] = useState(false) + const [isTocOpen, setIsTocOpen] = useState(false) + const [activeChapter, setActiveChapter] = useState(1) + const tocRef = useRef(null) + const heroRef = useRef(null) + const ctaBannerRef = useRef(null) + + // Scroll detection to show floating ToC + useEffect(() => { + const handleScroll = () => { + const heroElement = heroRef.current + const ctaBannerElement = ctaBannerRef.current + + if (heroElement && ctaBannerElement) { + const heroRect = heroElement.getBoundingClientRect() + const ctaBannerRect = ctaBannerElement.getBoundingClientRect() + + // Show floating ToC when the hero section is completely out of view + // but hide it when the CTA banner comes into view + if (heroRect.bottom < 0 && ctaBannerRect.top > window.innerHeight) { + setShowFloatingToc(true) + } else { + setShowFloatingToc(false) + setIsTocOpen(false) + } + } + } + + window.addEventListener('scroll', handleScroll) + return () => window.removeEventListener('scroll', handleScroll) + }, []) + + // Active chapter detection + useEffect(() => { + const handleScroll = () => { + const chapters = pageData.pageChapters + const scrollY = window.scrollY + 100 // Offset for better detection + + for (let i = chapters.length - 1; i >= 0; i--) { + const chapterElement = document.getElementById(`chapter-${i + 1}`) + if (chapterElement && scrollY >= chapterElement.offsetTop) { + setActiveChapter(i + 1) + break + } + } + } + + window.addEventListener('scroll', handleScroll) + return () => window.removeEventListener('scroll', handleScroll) + }, []) + + // Close ToC when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const isOutsideFloating = tocRef.current && !tocRef.current.contains(event.target as Node) + + if (isOutsideFloating) { + setIsTocOpen(false) + } + } + + if (isTocOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [isTocOpen]) + + return ( + + + {showFloatingToc && ( + + )} + + {/* Intro section */} +
+ + +
+ {/* Intro text */} +
+

{pageData.heroSection.subheader}

+

{pageData.heroSection.cta}

+
+ + {/* Table of contents */} +
    + {pageData.pageChapters.map((chapter, chapterIndex) => ( +
  1. + + + {chapterIndex + 1} + {' '} + {chapter.shortTitle} + +
  2. + ))} +
+
+ + +
+ + {pageData.pageChapters.map((chapter, chapterIndex) => ( + + {chapter.sections.map((section, sectionIndex) => ( + + ))} + + ))} + + +
+ ) +} + +// Component for the participants list +const ParticipantsList = () => { + const [shuffledParticipants, setShuffledParticipants] = useState(pageData.participantsList) + + useEffect(() => { + // Simple shuffle after mount, no animation because it's at the bottom of the page + const shuffled = [...pageData.participantsList].sort(() => Math.random() - 0.5) + setShuffledParticipants(shuffled) + }, []) + + return ( +
+
+

Thank you

+

+ A special thanks to the following companies for participating in this year's survey. +

+
+ +
    + {shuffledParticipants.map((participant, index) => ( +
  • + + {participant.company} + +
  • + ))} +
+
+ ) +} + +// Component for the 'Builders choose Supabase' CTA at the bottom of the page +const CTABanner = forwardRef((props, ref) => { + const sendTelemetryEvent = useSendTelemetryEvent() + return ( +
+
+

Builders choose Supabase

+

+ Supabase is the Postgres development platform. Build your startup with a Postgres + database, Authentication, instant APIs, Edge Functions, Realtime subscriptions, Storage, + and Vector embeddings. +

+
+
+ + +
+
+ ) +}) + +CTABanner.displayName = 'CTABanner' diff --git a/apps/www/app/state-of-startups/components/DecorativeProgressBar.tsx b/apps/www/app/state-of-startups/components/DecorativeProgressBar.tsx new file mode 100644 index 00000000000..c032444d73e --- /dev/null +++ b/apps/www/app/state-of-startups/components/DecorativeProgressBar.tsx @@ -0,0 +1,102 @@ +'use client' + +interface ProgressBarProps { + className?: string + backgroundClassName?: string + foregroundClassName?: string + animationDelay: string + reverse?: boolean +} + +export function ProgressBar({ + className = 'h-4', + backgroundClassName = 'bg-foreground-muted/80', + foregroundClassName = 'bg-brand', + animationDelay, + reverse = false, +}: ProgressBarProps) { + return ( +
+ {/* Background bar (static) */} +
+ + {/* Animated foreground bar */} +
+
+ ) +} + +interface DecorativeProgressBarProps { + /** Whether to reverse the animation direction */ + reverse?: boolean + /** Whether to align to start or end */ + align?: 'start' | 'end' +} + +const PROGRESS_BARS = [ + { + height: 'h-4', + bgColor: 'bg-foreground-muted/80', + fgColor: 'bg-brand-300', + animationDelay: '0s', + }, + { + height: 'h-8', + bgColor: 'bg-foreground-muted/50', + fgColor: 'bg-brand', + animationDelay: '0.3s', + }, + { + height: 'h-12', + bgColor: 'bg-foreground-muted/20', + fgColor: 'bg-brand-500', + animationDelay: '0.6s', + }, +] + +export function DecorativeProgressBar({ + reverse = false, + align = 'start', +}: DecorativeProgressBarProps) { + const progressBars = PROGRESS_BARS + + return ( + + ) +} diff --git a/apps/www/app/state-of-startups/components/StateOfStartupsAuroraHeader.tsx b/apps/www/app/state-of-startups/components/StateOfStartupsAuroraHeader.tsx new file mode 100644 index 00000000000..6b10e479dac --- /dev/null +++ b/apps/www/app/state-of-startups/components/StateOfStartupsAuroraHeader.tsx @@ -0,0 +1,64 @@ +'use client' + +import { useReducedMotion } from 'framer-motion' +import { useTheme } from 'next-themes' +import { useEffect, useState } from 'react' +import UnicornScene from 'unicornstudio-react/next' + +export function StateOfStartupsAuroraHeader() { + const [shaderLoaded, setShaderLoaded] = useState(false) + const prefersReducedMotion = useReducedMotion() + + return ( +
+ {/* Aurora shader scene — hidden when user prefers reduced motion */} + {!prefersReducedMotion && ( + + )} + + {/* Placeholder — visible until shader loads, fades out on load */} +
+ ) +} diff --git a/apps/www/app/state-of-startups/components/StateOfStartupsHeader.tsx b/apps/www/app/state-of-startups/components/StateOfStartupsHeader.tsx new file mode 100644 index 00000000000..3a2de4ee291 --- /dev/null +++ b/apps/www/app/state-of-startups/components/StateOfStartupsHeader.tsx @@ -0,0 +1,136 @@ +import { useMemo } from 'react' + +import { ProgressBar } from './DecorativeProgressBar' + +interface StateOfStartupsHeaderProps { + showProgressBars?: boolean +} + +const DiagonalStripes = () => ( +
+) + +const HeaderProgressBar = ({ + delay = 0, + progressBgColor = 'bg-foreground-muted/80', + progressFgColor, +}: { + delay?: number + progressBgColor?: string + progressFgColor?: string +}) => ( +
+ +
+) + +interface TextBlockProps { + text: string + bgColor: string + textColor: string + showProgressBar?: boolean +} + +const TextBlock = ({ + text, + bgColor, + textColor, + showProgressBar = true, + delay = 0, + progressBgColor, + progressFgColor, +}: TextBlockProps & { delay?: number; progressBgColor?: string; progressFgColor?: string }) => ( +
+
+ + {text} + +
+ {showProgressBar ? ( + + ) : ( + + )} +
+) + +export function StateOfStartupsHeader({ showProgressBars = true }: StateOfStartupsHeaderProps) { + // Generate stable random delays between 0.2 and 2 seconds for each TextBlock + const randomDelays = useMemo( + () => [ + 0.2 + Math.random() * 1.8, + 0.2 + Math.random() * 1.8, + 0.2 + Math.random() * 1.8, + 0.2 + Math.random() * 1.8, + ], + [] + ) + + return ( +
+
+
+

State of Startups 2026

+ + + + +
+
+
+ ) +} diff --git a/apps/www/app/state-of-startups/components/SurveyChapter.tsx b/apps/www/app/state-of-startups/components/SurveyChapter.tsx new file mode 100644 index 00000000000..9532097bd60 --- /dev/null +++ b/apps/www/app/state-of-startups/components/SurveyChapter.tsx @@ -0,0 +1,85 @@ +'use client' + +import { SurveyPullQuote } from './SurveyPullQuote' + +import './surveyResults.css' + +import { AccentContext } from './accent-context' +import { DecorativeProgressBar } from './DecorativeProgressBar' + +interface SurveyChapterProps { + number: number + title: string + shortTitle: string + description: string + pullQuote?: { + quote: string + author: string + authorPosition: string + authorAvatar: string + } + children: React.ReactNode +} + +export function SurveyChapter({ + number, + title, + shortTitle, + description, + pullQuote, + children, +}: SurveyChapterProps) { + const accent = 'green' + + return ( + +
+
+ {/* Chapter header */} +
+ {/* Decorative progress bar */} + + {/* Text content */} +
+
+

+ {shortTitle} +

+

+ {title} +

+
+

+ {description} +

+
+
+ {/* Chapter content */} +
{children}
+
+ + {pullQuote && ( + + )} +
+
+ ) +} diff --git a/apps/www/app/state-of-startups/components/SurveyChapterSection.tsx b/apps/www/app/state-of-startups/components/SurveyChapterSection.tsx new file mode 100644 index 00000000000..acd6fdbdd18 --- /dev/null +++ b/apps/www/app/state-of-startups/components/SurveyChapterSection.tsx @@ -0,0 +1,117 @@ +'use client' + +import { useAccent } from './accent-context' +import { SurveyStatCard } from './SurveyStatCard' +import { SurveyWordCloud } from './SurveyWordCloud' +import { SurveySummarizedAnswer } from './SurveySummarizedAnswer' +import { SurveyRankedAnswersPair } from './SurveyRankedAnswersPair' +import { SurveySectionBreak } from './SurveySectionBreak' +import { AcceleratorParticipationChart } from './charts/AcceleratorParticipationChart' +import { RoleChart } from './charts/RoleChart' +import { IndustryChart } from './charts/IndustryChart' +import { FundingStageChart } from './charts/FundingStageChart' +import { DatabasesChart } from './charts/DatabasesChart' +import { AIModelsChart } from './charts/AIModelsChart' +import { LocationChart } from './charts/LocationChart' +import { SalesToolsChart } from './charts/SalesToolsChart' +import { AICodingToolsChart } from './charts/AICodingToolsChart' +import { RegularSocialMediaUseChart } from './charts/RegularSocialMediaUseChart' +import { NewIdeasChart } from './charts/NewIdeasChart' +import { InitialPayingCustomersChart } from './charts/InitialPayingCustomersChart' +import { WorldOutlookChart } from './charts/WorldOutlookChart' +import { BiggestChallengeChart } from './charts/BiggestChallengeChart' + +interface SurveyChapterSectionProps { + title: string + description: string + stats?: Array<{ percent: number; label: string }> + charts?: string[] + + wordCloud?: { + label: string + words: { text: string; count: number }[] + } + summarizedAnswer?: { + label: string + answers: string[] + } + rankedAnswersPair?: Array<{ label: string; answers: string[] }> + children?: React.ReactNode +} + +export function SurveyChapterSection({ + title, + description, + stats, + charts, + wordCloud, + summarizedAnswer, + rankedAnswersPair, + children, +}: SurveyChapterSectionProps) { + const accent = useAccent() + + const eyebrowColor = 'text-brand-link dark:text-brand' + + const chartComponents = { + RoleChart, + IndustryChart, + FundingStageChart, + AcceleratorParticipationChart, + DatabasesChart, + AICodingToolsChart, + AIModelsChart, + RegularSocialMediaUseChart, + NewIdeasChart, + InitialPayingCustomersChart, + SalesToolsChart, + WorldOutlookChart, + BiggestChallengeChart, + LocationChart, + } + + return ( +
+
+
+

+ {title} +

+

+ {description} +

+
+ + {stats && ( + + )} + + {charts?.map((chartName, index) => { + const ChartComponent = chartComponents[chartName as keyof typeof chartComponents] + return ChartComponent ? : null + })} + + {rankedAnswersPair && } + + {wordCloud && } + + {summarizedAnswer && ( + + )} + + {children} +
+ + +
+ ) +} diff --git a/apps/www/app/state-of-startups/components/SurveyChart.tsx b/apps/www/app/state-of-startups/components/SurveyChart.tsx new file mode 100644 index 00000000000..caff8c2e6c5 --- /dev/null +++ b/apps/www/app/state-of-startups/components/SurveyChart.tsx @@ -0,0 +1,572 @@ +'use client' +import { useEffect, useState, useRef, useCallback } from 'react' +import { createClient } from '@supabase/supabase-js' +import { motion } from 'framer-motion' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from 'ui' +import { ChevronsUpDown } from 'lucide-react' +import TwoOptionToggle from './TwoOptionToggle' +import CodeBlock from '~/components/CodeBlock/CodeBlock' +import { useAccent } from './accent-context' + +// Separate Supabase client for survey project +const externalSupabase = createClient( + process.env.NEXT_PUBLIC_SURVEY_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SURVEY_SUPABASE_ANON_KEY! +) + +// Sentinel for “no filter” +const NO_FILTER = 'unset' + +interface FilterOption { + value: string + label: string +} + +interface FilterConfig { + label: string + options: FilterOption[] +} + +interface Filters { + [key: string]: FilterConfig +} + +interface ChartDataItem { + label: string + value: number + rawValue: number +} + +interface FilterColumnConfig { + label: string + options: string[] +} + +// Sometimes the label doesn't match the value, so we need to map them +// Also, our options aren't always in a predicatable order, so we need to map them too +const FILTER_COLUMN_CONFIGS: Record = { + team_size: { + label: 'Team Size', + options: ['1–10', '11–50', '51–100', '101–250', '250+'], + }, + money_raised: { + label: 'Money Raised', + options: ['USD $0–10M', 'USD $11–50M', 'USD $51–100M', 'USD $100M+'], + }, + person_age: { + label: 'Age', + options: ['18–21', '22–29', '30–39', '40–49', '50–59', '60+'], + }, + location: { + label: 'Location', + options: [ + 'Africa', + 'Asia', + 'Europe', + 'Middle East', + 'North America', + 'South America', + 'Remote', + ], + }, +} + +function useFilterOptions(filterColumns: string[]) { + // Build filters synchronously since everything is predefined + const filters: Filters = {} + + for (const column of filterColumns) { + const config = FILTER_COLUMN_CONFIGS[column] + + if (!config) { + console.warn(`No configuration found for filter column: ${column}`) + continue + } + + filters[column] = { + label: config.label, + options: config.options.map((option) => ({ value: option, label: option })), + } + } + + return { filters } +} + +// Fetch survey data using secure database functions +function useSurveyData( + shouldFetch: boolean, + functionName: string, + functionParams: (activeFilters: Record) => Record, + activeFilters: Record +) { + const [chartData, setChartData] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!shouldFetch) return + + async function fetchData() { + try { + setIsLoading(true) + setError(null) + + let data, fetchError + + const functionParamsData = functionParams(activeFilters) + const { data: functionData, error: functionError } = await externalSupabase.rpc( + functionName, + functionParamsData + ) + data = functionData + fetchError = functionError + + if (fetchError) { + console.error('Error executing SQL query:', fetchError) + setError(fetchError.message) + return + } + + // Calculate total for percentage calculation + const total = data.reduce( + (sum: number, row: any) => sum + parseInt(row.count || row.total), + 0 + ) + + // Transform the data to match chart format + const processedData = data.map((row: any) => { + const count = parseInt(row.count || row.total) + const rawPercentage = total > 0 ? (count / total) * 100 : 0 + const roundedPercentage = Math.round(rawPercentage) + + return { + label: row.label || row.value || row[Object.keys(row)[0]], // Get the first column as label + value: roundedPercentage, + rawValue: rawPercentage, // Keep the raw value for bar scaling + } + }) + + setChartData(processedData) + } catch (err: any) { + console.error('Error in fetchData:', err) + setError(err.message) + } finally { + setIsLoading(false) + } + } + + fetchData() + }, [shouldFetch, functionName, functionParams, activeFilters]) + + return { chartData, isLoading, error } +} + +interface SurveyChartProps { + title: string + targetColumn: string + filterColumns: string[] + generateSQLQuery?: (activeFilters: Record) => string + functionName: string +} + +export function SurveyChart({ + title, + targetColumn, // Used for SQL query generation when generateSQLQuery is provided + filterColumns, + generateSQLQuery, + functionName, +}: SurveyChartProps) { + const accent = useAccent() + const accentBg = 'hsl(var(--brand-300))' + const accentBarFg = 'bg-brand' + const accentBarText = 'text-brand-link dark:text-brand' + + const [isInView, setIsInView] = useState(false) + const chartRef = useRef(null) + const [shouldAnimateBars, setShouldAnimateBars] = useState(false) + const [hasLoadedOnce, setHasLoadedOnce] = useState(false) + + // Intersection observer to trigger chart data loading via database function + useEffect(() => { + const chartRefCurrent = chartRef.current + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !hasLoadedOnce) { + setIsInView(true) + setHasLoadedOnce(true) + observer.disconnect() // Only trigger once + } + }) + }, + { + threshold: 0.1, + rootMargin: '128px', // Start loading before the component comes into view + } + ) + + if (chartRefCurrent) { + observer.observe(chartRefCurrent) + } + + return () => { + if (chartRefCurrent) { + observer.unobserve(chartRefCurrent) + } + } + }, [hasLoadedOnce]) + + // Each chart uses a subset of available filters, defined below + const { filters } = useFilterOptions(filterColumns) + + // Start with all filters unset (showing "all") + const [activeFilters, setActiveFilters] = useState( + filterColumns.reduce( + (acc: Record, col: string) => ({ ...acc, [col]: NO_FILTER }), + {} + ) + ) + + // Build function parameters based on filterColumns + const buildFunctionParams = useCallback((activeFilters: Record) => { + const params: Record = {} + + // Convert single values to arrays for the function parameters + if (activeFilters.person_age && activeFilters.person_age !== 'unset') { + params.person_age_filter = [activeFilters.person_age] + } + if (activeFilters.location && activeFilters.location !== 'unset') { + params.location_filter = [activeFilters.location] + } + if (activeFilters.money_raised && activeFilters.money_raised !== 'unset') { + params.money_raised_filter = [activeFilters.money_raised] + } + if (activeFilters.team_size && activeFilters.team_size !== 'unset') { + params.team_size_filter = [activeFilters.team_size] + } + + return params + }, []) + + // Use the custom hook to fetch data + const { + chartData, + isLoading: dataLoading, + error: dataError, + } = useSurveyData(isInView, functionName, buildFunctionParams, activeFilters) + + // Reset animation state when filters change + useEffect(() => { + setShouldAnimateBars(false) + }, [activeFilters]) + + // Trigger bar animation when data loads + useEffect(() => { + if (!dataLoading && chartData.length > 0 && !shouldAnimateBars) { + // Small delay to ensure DOM is ready + const timer = setTimeout(() => { + setShouldAnimateBars(true) + }, 100) + + return () => clearTimeout(timer) + } + }, [dataLoading, chartData.length, shouldAnimateBars]) + + const [view, setView] = useState<'chart' | 'sql'>('chart') + + // Handle both view change and expansion via a wrapper function + const handleViewChange = (newView: 'chart' | 'sql') => { + setView(newView) + setIsExpanded(true) + } + + const setFilterValue = (filterKey: string, value: string) => { + setActiveFilters((prev: Record) => ({ + ...prev, + [filterKey]: value, + })) + } + + // Find the maximum value for scaling the bars + const maxValue = chartData.length > 0 ? Math.max(...chartData.map((item) => item.value)) : 0 + + // State for expand/collapse button + const [isExpanded, setIsExpanded] = useState(false) + + // Fixed height for all states (loading, error, loaded collapsed) + const FIXED_HEIGHT = 300 // px + const BUTTON_AREA_HEIGHT = 40 // px + const CHART_HEIGHT = FIXED_HEIGHT - BUTTON_AREA_HEIGHT // px + + const skeletonData = [ + { label: 'Loading', value: 0, rawValue: 0 }, + { label: 'Loading', value: 0, rawValue: 0 }, + { label: 'Loading', value: 0, rawValue: 0 }, + ] + + const displayData = dataLoading ? skeletonData : chartData || [] + + return ( +
+
+

Q&A

+

{title}

+
+ +
+ {/* Filters and toggle */} +
+ {filters && activeFilters && setFilterValue && ( +
+ {Object.entries(filters).map(([filterKey, filterConfig]) => ( + + ))} +
+ )} +
+ +
+
+ + {dataError ? ( +
+

Error: {dataError}

+
+ ) : view === 'chart' ? ( +
+ {chartData.length > 0 ? ( +
+ {displayData.map((item, index) => ( +
+ {/* Text above the bar */} +
+ {item.label} + {item.value < 1 ? '<1%' : `${item.value}%`} +
+ + {/* Progress bar */} +
+ {/* Background pattern for the entire bar */} +
+ + {/* Filled portion of the bar */} +
+ {/* Foreground pattern for the filled portion */} +
+
+
+
+ ))} +
+ ) : ( +
+

+ No responses match those filters. Maybe next year? +

+ +
+ )} +
+ ) : ( +
+ {generateSQLQuery ? ( + {generateSQLQuery(activeFilters)} + ) : ( + + {`// Function call: ${functionName}(${JSON.stringify(buildFunctionParams(activeFilters), null, 2)})`} + + )} +
+ )} + + {/* Expand button overlay - only show for chart view */} + {view === 'chart' && + !isExpanded && + !dataLoading && + !dataError && + chartData.length > 3 && ( +
+ +
+ )} + +
+
+ ) +} + +// Helper to build SQL WHERE clauses from active filters +// Accepts optional initialClauses for charts that need extra constraints +// (e.g., "column IS NOT NULL") +// Note: This function is used by chart components that generate SQL queries +export function buildWhereClause( + activeFilters: Record, + initialClauses: string[] = [] +) { + const whereClauses: string[] = [...initialClauses] + + for (const [column, value] of Object.entries(activeFilters)) { + if (value && value !== NO_FILTER) { + whereClauses.push(`${column} = '${value}'`) + } + } + + return whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' +} + +// Dropdown filter component +function SurveyFilter({ + filterKey, + filterConfig, + selectedValue, + setFilterValue, +}: { + filterKey: string + filterConfig: { + label: string + options: { value: string; label: string }[] + } + selectedValue: string + setFilterValue: (filterKey: string, value: string) => void +}) { + return ( + + + + + + {filterConfig.options + .filter((opt) => opt.value !== NO_FILTER) + .map((option) => ( + setFilterValue(filterKey, option.value)} + className={selectedValue === option.value ? 'text-brand-600' : ''} + > + {option.label} + + ))} + + {selectedValue !== NO_FILTER && ( +
+ setFilterValue(filterKey, NO_FILTER)} + className="text-foreground-lighter" + > + Clear + +
+ )} +
+
+ ) +} diff --git a/apps/www/app/state-of-startups/components/SurveyPullQuote.tsx b/apps/www/app/state-of-startups/components/SurveyPullQuote.tsx new file mode 100644 index 00000000000..de3bea6c1b6 --- /dev/null +++ b/apps/www/app/state-of-startups/components/SurveyPullQuote.tsx @@ -0,0 +1,48 @@ +import Image from 'next/image' +import { SurveySectionBreak } from './SurveySectionBreak' + +export function SurveyPullQuote({ + quote, + author, + authorPosition, + authorAvatar, +}: { + quote: string + author: string + authorPosition: string + authorAvatar: string +}) { + return ( + <> + + + + ) +} diff --git a/apps/www/app/state-of-startups/components/SurveyRankedAnswersPair.tsx b/apps/www/app/state-of-startups/components/SurveyRankedAnswersPair.tsx new file mode 100644 index 00000000000..5dbcf64a66d --- /dev/null +++ b/apps/www/app/state-of-startups/components/SurveyRankedAnswersPair.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useAccent } from './accent-context' + +export function SurveyRankedAnswersPair({ + rankedAnswersPair, +}: { + rankedAnswersPair: Array<{ label: string; answers: string[] }> +}) { + const accent = useAccent() + + const accentBlocks = ['bg-brand', 'bg-brand-500', 'bg-brand-300'] + + const accentRankText = 'text-brand' + + return ( + + ) +} diff --git a/apps/www/app/state-of-startups/components/SurveySectionBreak.tsx b/apps/www/app/state-of-startups/components/SurveySectionBreak.tsx new file mode 100644 index 00000000000..12612a9f0d8 --- /dev/null +++ b/apps/www/app/state-of-startups/components/SurveySectionBreak.tsx @@ -0,0 +1,19 @@ +import { cn } from 'ui' + +export function SurveySectionBreak({ className }: { className?: string }) { + return ( +