diff --git a/apps/www/components/SurveyResults/AICodingToolsChart.tsx b/apps/www/components/SurveyResults/AICodingToolsChart.tsx new file mode 100644 index 00000000000..91031f207e7 --- /dev/null +++ b/apps/www/components/SurveyResults/AICodingToolsChart.tsx @@ -0,0 +1,39 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateAICodingToolsSQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.person_age !== 'unset') { + whereClauses.push(`person_age = '${activeFilters.person_age}'`) + } + + if (activeFilters.team_count !== 'unset') { + whereClauses.push(`team_count = '${activeFilters.team_count}'`) + } + + if (activeFilters.funding_stage !== 'unset') { + whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return ` +SELECT + unnest(ai_coding_tools) AS technology, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY technology +ORDER BY total DESC; +` +} + +export function AICodingToolsChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/AIModelsChart.tsx b/apps/www/components/SurveyResults/AIModelsChart.tsx new file mode 100644 index 00000000000..e95b2ed135e --- /dev/null +++ b/apps/www/components/SurveyResults/AIModelsChart.tsx @@ -0,0 +1,37 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateDatabasesSQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.funding_stage !== 'unset') { + whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`) + } + + if (activeFilters.startup_age !== 'unset') { + whereClauses.push(`startup_age = '${activeFilters.startup_age}'`) + } + + if (activeFilters.currently_monetizing !== 'unset') { + whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `SELECT + unnest(ai_models_used) AS model, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY model +ORDER BY total DESC;` +} + +export function AIModelsChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/AcceleratorParticipationChart.tsx b/apps/www/components/SurveyResults/AcceleratorParticipationChart.tsx new file mode 100644 index 00000000000..33ab009c0f6 --- /dev/null +++ b/apps/www/components/SurveyResults/AcceleratorParticipationChart.tsx @@ -0,0 +1,41 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateAcceleratorParticipationSQL(activeFilters: Record) { + const whereClauses = [] + + // Always filter out NULL values + whereClauses.push(`accelerator_participation_normalized IS NOT NULL`) + + if (activeFilters.previous_company !== 'unset') { + whereClauses.push(`previous_company = '${activeFilters.previous_company}'`) + } + + if (activeFilters.headquarters !== 'unset') { + whereClauses.push(`headquarters = '${activeFilters.headquarters}'`) + } + + if (activeFilters.funding_stage !== 'unset') { + whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`) + } + + const whereClause = `WHERE ${whereClauses.join('\n AND ')}` + + return `SELECT + accelerator_participation_normalized, + COUNT(*) AS total +FROM responses_2025 +${whereClause} +GROUP BY accelerator_participation_normalized +ORDER BY total DESC;` +} + +export function AcceleratorParticipationChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/AgeChart.tsx b/apps/www/components/SurveyResults/AgeChart.tsx new file mode 100644 index 00000000000..47ec8f63722 --- /dev/null +++ b/apps/www/components/SurveyResults/AgeChart.tsx @@ -0,0 +1,34 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +// Generate SQL query for team count chart +function generatePersonAgeSQL(activeFilters) { + const whereClauses = [] + + if (activeFilters.role !== 'unset') { + whereClauses.push(`role = '${activeFilters.role}'`) + } + + if (activeFilters.headquarters !== 'unset') { + whereClauses.push(`headquarters = '${activeFilters.headquarters}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `SELECT + person_age, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY person_age +ORDER BY person_age ASC` +} + +export function PersonAgeChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/BackendStackChart.tsx b/apps/www/components/SurveyResults/BackendStackChart.tsx new file mode 100644 index 00000000000..41bab7ad19e --- /dev/null +++ b/apps/www/components/SurveyResults/BackendStackChart.tsx @@ -0,0 +1,39 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateBackendStackSQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.headquarters !== 'unset') { + whereClauses.push(`headquarters = '${activeFilters.headquarters}'`) + } + + if (activeFilters.industry_normalized !== 'unset') { + whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`) + } + + if (activeFilters.currently_monetizing !== 'unset') { + whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return ` +SELECT + unnest(backend_stack) AS technology, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY technology +ORDER BY total DESC; +` +} + +export function BackendStackChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/BiggestChallengeChart.tsx b/apps/www/components/SurveyResults/BiggestChallengeChart.tsx new file mode 100644 index 00000000000..23fe27ba1bf --- /dev/null +++ b/apps/www/components/SurveyResults/BiggestChallengeChart.tsx @@ -0,0 +1,53 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +// Generate SQL query for team count chart +function generateBiggestChallengeSQL(activeFilters) { + const whereClauses = [] + + // Always filter out NULL values + whereClauses.push(`biggest_challenge IS NOT NULL`) + + if (activeFilters.team_count !== 'unset') { + whereClauses.push(`team_count = '${activeFilters.team_count}'`) + } + + if (activeFilters.ai_models_used !== 'unset') { + whereClauses.push(`'${activeFilters.ai_models_used}' = ANY(ai_models_used)`) + } + + if (activeFilters.currently_monetizing !== 'unset') { + whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `WITH biggest_challenge_mapping AS ( + SELECT + biggest_challenge, + CASE + -- WHEN biggest_challenge = 'Customer acquisition' THEN 'Acquisition' + -- WHEN biggest_challenge = 'Technical complexity' THEN 'Technical' + WHEN biggest_challenge IN ('Customer acquisition', 'Technical complexity', 'Product-market fit', 'Product-market fit', 'Fundraising', 'Hiring', 'Other') THEN biggest_challenge + ELSE 'Other' + END AS biggest_challenge_clean + FROM responses_2025 + ${whereClause} +) +SELECT + biggest_challenge_clean AS biggest_challenge, + COUNT(*) AS total +FROM biggest_challenge_mapping +GROUP BY biggest_challenge_clean +ORDER BY total DESC;` +} + +export function BiggestChallengeChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/CloudProvidersChart.tsx b/apps/www/components/SurveyResults/CloudProvidersChart.tsx new file mode 100644 index 00000000000..e84a5a55c9b --- /dev/null +++ b/apps/www/components/SurveyResults/CloudProvidersChart.tsx @@ -0,0 +1,41 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateCloudProvidersSQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.currently_monetizing !== 'unset') { + whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`) + } + + if (activeFilters.money_raised !== 'unset') { + whereClauses.push(`money_raised = '${activeFilters.money_raised}'`) + } + + if (activeFilters.backend_stack !== 'unset') { + whereClauses.push(`'${activeFilters.backend_stack}' = ANY(backend_stack)`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `WITH expanded AS ( + SELECT unnest(cloud_providers) AS technology + FROM responses_2025${whereClause ? '\n' + whereClause : ''} +) +SELECT + technology, + COUNT(*) AS total +FROM expanded +GROUP BY technology +ORDER BY total DESC;` +} + +export function CloudProvidersChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/DataWarehousesChart.tsx b/apps/www/components/SurveyResults/DataWarehousesChart.tsx new file mode 100644 index 00000000000..383b2acf830 --- /dev/null +++ b/apps/www/components/SurveyResults/DataWarehousesChart.tsx @@ -0,0 +1,48 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateDataWarehousesSQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.headquarters !== 'unset') { + whereClauses.push(`headquarters = '${activeFilters.headquarters}'`) + } + + if (activeFilters.industry_normalized !== 'unset') { + whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`) + } + + if (activeFilters.person_age !== 'unset') { + whereClauses.push(`person_age = '${activeFilters.person_age}'`) + } + + if (activeFilters.frontend_stack !== 'unset') { + whereClauses.push(`'${activeFilters.frontend_stack}' = ANY(frontend_stack)`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return ` +WITH expanded AS ( + SELECT unnest(data_warehouses) AS technology +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +) +SELECT + technology, + COUNT(*) AS total +FROM expanded +WHERE technology NOT IN ('We don''t use a data warehouse', 'Not sure') +GROUP BY technology +ORDER BY total DESC; +` +} + +export function DataWarehousesChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/DatabasesChart.tsx b/apps/www/components/SurveyResults/DatabasesChart.tsx new file mode 100644 index 00000000000..8e5193fa306 --- /dev/null +++ b/apps/www/components/SurveyResults/DatabasesChart.tsx @@ -0,0 +1,37 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateDatabasesSQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.team_count !== 'unset') { + whereClauses.push(`team_count = '${activeFilters.team_count}'`) + } + + if (activeFilters.ai_models_used !== 'unset') { + whereClauses.push(`'${activeFilters.ai_models_used}' = ANY(ai_models_used)`) + } + + if (activeFilters.backend_stack !== 'unset') { + whereClauses.push(`'${activeFilters.backend_stack}' = ANY(backend_stack)`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `SELECT + unnest(databases) AS technology, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY technology +ORDER BY total DESC;` +} + +export function DatabasesChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/FilterDropdown.tsx b/apps/www/components/SurveyResults/FilterDropdown.tsx new file mode 100644 index 00000000000..54e6d6d5e37 --- /dev/null +++ b/apps/www/components/SurveyResults/FilterDropdown.tsx @@ -0,0 +1,71 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Button, +} from 'ui' +import { ChevronsUpDown } from 'lucide-react' + +// Dropdown component for SQL +export function FilterDropdown({ + 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 !== 'unset') + .map((option) => ( + setFilterValue(filterKey, option.value)} + className={selectedValue === option.value ? 'text-brand-600' : ''} + > + {option.label} + + ))} + + {selectedValue !== 'unset' && ( +
+ setFilterValue(filterKey, 'unset')} + className="text-foreground-lighter" + > + Clear filter + +
+ )} +
+
+ ) +} diff --git a/apps/www/components/SurveyResults/FrontendStackChart.tsx b/apps/www/components/SurveyResults/FrontendStackChart.tsx new file mode 100644 index 00000000000..b3904eb8a31 --- /dev/null +++ b/apps/www/components/SurveyResults/FrontendStackChart.tsx @@ -0,0 +1,38 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateFrontendStackSQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.headquarters !== 'unset') { + whereClauses.push(`headquarters = '${activeFilters.headquarters}'`) + } + + if (activeFilters.industry_normalized !== 'unset') { + whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`) + } + + if (activeFilters.person_age !== 'unset') { + whereClauses.push(`person_age = '${activeFilters.person_age}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `SELECT + unnest(frontend_stack) AS technology, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY technology +ORDER BY total DESC; +` +} + +export function FrontendStackChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/FullTimeEmployees.tsx b/apps/www/components/SurveyResults/FullTimeEmployees.tsx new file mode 100644 index 00000000000..d41f27ec327 --- /dev/null +++ b/apps/www/components/SurveyResults/FullTimeEmployees.tsx @@ -0,0 +1,101 @@ +'use client' + +// import { Bar, BarChart, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts' +// import { ChartContainer } from 'ui' + +import { Bar, BarChart, CartesianGrid, LabelList, XAxis, YAxis, Cell } from 'recharts' + +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from 'ui' +import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from 'ui' + +export const description = 'A bar chart with a custom label' + +// const chartData = [ +// { month: "January", desktop: 186, mobile: 80 }, +// { month: "February", desktop: 305, mobile: 200 }, +// { month: "March", desktop: 237, mobile: 120 }, +// { month: "April", desktop: 73, mobile: 190 }, +// { month: "May", desktop: 209, mobile: 130 }, +// { month: "June", desktop: 214, mobile: 140 }, +// ] + +const chartData = [ + { label: '1–10', value: 91.1 }, + { label: '11–50', value: 6.3 }, + { label: '51–100', value: 1.2 }, + { label: '101–250', value: 0.8 }, + { label: '250+', value: 0.6 }, +] + +const chartConfig = { + value: { + label: 'Value', + color: 'hsl(var(--brand-default))', + }, + label: { + color: '#ff0000', + }, +} satisfies ChartConfig + +export function FullTimeEmployees() { + return ( + + + How many full-time employees does your startup have? + + + + + + value.slice(0, 3)} + hide={false} // Show the YAxis + width={64} // Adjust width as needed for your labels + tick={{ + // fill: 'hsl(var(--foreground-lighter))', // Text color TODO not working + className: 'text-foreground-lighter', // Also not accessible + fontSize: 12, + textAnchor: 'start', // Left align + dx: 0, // Horizontal offset + }} + /> + + {/* } + /> */} + + {/* To set per-bar colors in Recharts, `fill` should be applied to the Cell child */} + {chartData.map((entry, index) => ( + + ))} + `${value}%`} + /> + + + + + + ) +} diff --git a/apps/www/components/SurveyResults/FundingStageChart.tsx b/apps/www/components/SurveyResults/FundingStageChart.tsx new file mode 100644 index 00000000000..70edddfcbdc --- /dev/null +++ b/apps/www/components/SurveyResults/FundingStageChart.tsx @@ -0,0 +1,53 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateFundingStageSQL(activeFilters) { + const whereClauses = [] + + if (activeFilters.headquarters !== 'unset') { + whereClauses.push(`headquarters = '${activeFilters.headquarters}'`) + } + + if (activeFilters.team_count !== 'unset') { + whereClauses.push(`team_count = '${activeFilters.team_count}'`) + } + + if (activeFilters.previous_company !== 'unset') { + // Convert string to boolean for the SQL query + const previousCompanyBool = activeFilters.previous_company === 'true' + whereClauses.push(`previous_company = ${previousCompanyBool}`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `SELECT * FROM ( + SELECT + CASE + WHEN funding_stage IN ('Series A', 'Series B', 'Series C', 'Series D or later') THEN 'Series A+' + ELSE funding_stage + END AS funding_stage, + COUNT(*) AS total + FROM responses_2025${whereClause ? '\n' + whereClause : ''} + GROUP BY CASE + WHEN funding_stage IN ('Series A', 'Series B', 'Series C', 'Series D or later') THEN 'Series A+' + ELSE funding_stage + END + ) subquery + ORDER BY CASE + WHEN funding_stage = 'Bootstrapped' THEN 1 + WHEN funding_stage = 'Pre-seed' THEN 2 + WHEN funding_stage = 'Seed' THEN 3 + WHEN funding_stage = 'Series A+' THEN 4 + ELSE 5 + END;` +} + +export function FundingStageChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/GenericChartWithQuery.tsx b/apps/www/components/SurveyResults/GenericChartWithQuery.tsx new file mode 100644 index 00000000000..736aed53ec0 --- /dev/null +++ b/apps/www/components/SurveyResults/GenericChartWithQuery.tsx @@ -0,0 +1,431 @@ +'use client' +import { useEffect, useState, useRef } from 'react' +import { createClient } from '@supabase/supabase-js' +import { Card, CardContent, CardHeader, CardTitle, Button } from 'ui' +import { FilterDropdown } from './FilterDropdown' +import { ChevronDown, UnfoldVertical } from 'lucide-react' +import TwoOptionToggle from '../../../studio/components/ui/TwoOptionToggle' +import { SurveyCodeWindow } from './SurveyCodeWindow' +import CodeBlock from '~/components/CodeBlock/CodeBlock' + +// Create a separate Supabase client for your external project +const externalSupabase = createClient( + process.env.NEXT_PUBLIC_SURVEY_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SURVEY_SUPABASE_ANON_KEY! +) + +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 +} + +// Custom hook to fetch filter options from Supabase +function useFilterOptions(filterColumns: string[], shouldFetch: boolean) { + const [filters, setFilters] = useState({}) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!shouldFetch) return + + async function fetchFilterOptions() { + try { + setIsLoading(true) + setError(null) + + console.log('Fetching filter options from Supabase for columns:', filterColumns) + + const filterOptions: Filters = {} + + for (const column of filterColumns) { + const { data, error: fetchError } = await externalSupabase + .from('responses_2025') + .select(column) + .not(column, 'is', null) + + if (fetchError) { + console.error(`Error fetching ${column} options:`, fetchError) + continue + } + + console.log(`Raw data for ${column}:`, data) + + // Extract all individual values from PostgreSQL arrays + const allValues = data.flatMap((row: any) => { + const value = row[column] + console.log( + `Processing value for ${column}:`, + value, + typeof value, + Array.isArray(value) + ) + + if (Array.isArray(value)) { + // Handle PostgreSQL arrays + return value.filter((item: any) => item && item.length > 0) + } else if (typeof value === 'string' && value.startsWith('{') && value.endsWith('}')) { + // Handle PostgreSQL array format like {Firebase,MongoDB,MySQL} + const innerContent = value.slice(1, -1) // Remove { and } + if (innerContent.trim() === '') { + return [] + } + return innerContent + .split(',') + .map((item: string) => item.trim()) + .filter((item: string) => item.length > 0) + } + return [value] + }) + + console.log(`All values for ${column}:`, allValues) + + // Get unique values and sort them + const uniqueValues = [...new Set(allValues)].sort() + + console.log(`Unique values for ${column}:`, uniqueValues) + + filterOptions[column] = { + label: column.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase()), + options: [ + { + value: 'unset', + label: `All ${column.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase())}s`, + }, + ...uniqueValues.map((value) => ({ + value: String(value), // Convert to string for React + label: String(value), // Convert to string for display + })), + ], + } + } + + console.log('Final filter options:', filterOptions) + setFilters(filterOptions) + } catch (err: any) { + console.error('Error fetching filter options:', err) + setError(err.message) + } finally { + setIsLoading(false) + } + } + + fetchFilterOptions() + }, [filterColumns, shouldFetch]) + + return { filters, isLoading, error } +} + +// Custom hook to fetch survey data using SQL query via RPC +function useSurveyData(sqlQuery: string, shouldFetch: boolean) { + 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) + + console.log('Executing SQL query:', sqlQuery) + + // Execute the SQL query using Supabase RPC + const { data, error: fetchError } = await externalSupabase.rpc('execute_sql', { + query: sqlQuery, + }) + + if (fetchError) { + console.error('Error executing SQL query:', fetchError) + setError(fetchError.message) + return + } + + console.log('Raw data from SQL query:', data) + + // Calculate total for percentage calculation + const total = data.reduce((sum: number, row: any) => sum + parseInt(row.total), 0) + + // Transform the data to match chart format + const processedData = data.map((row: any) => { + const rawPercentage = total > 0 ? (parseInt(row.total) / 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 + } + }) + + console.log('Processed chart data:', processedData) + setChartData(processedData) + } catch (err: any) { + console.error('Error in fetchData:', err) + setError(err.message) + } finally { + setIsLoading(false) + } + } + + fetchData() + }, [sqlQuery, shouldFetch]) + + return { chartData, isLoading, error } +} + +interface GenericChartWithQueryProps { + title: string + targetColumn: string + filterColumns: string[] + generateSQLQuery: (activeFilters: Record) => string +} + +export function GenericChartWithQuery({ + title, + targetColumn, + filterColumns, + generateSQLQuery, +}: GenericChartWithQueryProps) { + const [isInView, setIsInView] = useState(false) + const chartRef = useRef(null) + const [shouldAnimateBars, setShouldAnimateBars] = useState(false) + const [hasLoadedOnce, setHasLoadedOnce] = useState(false) + + // Intersection observer to trigger data loading + useEffect(() => { + 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 (chartRef.current) { + observer.observe(chartRef.current) + } + + return () => { + if (chartRef.current) { + observer.unobserve(chartRef.current) + } + } + }, [hasLoadedOnce]) + + // Get dynamic filter options + const { + filters, + isLoading: filtersLoading, + error: filtersError, + } = useFilterOptions(filterColumns, isInView) + + // Start with all filters unset (showing "all") + const [activeFilters, setActiveFilters] = useState( + filterColumns.reduce( + (acc: Record, col: string) => ({ ...acc, [col]: 'unset' }), + {} + ) + ) + + // Generate the SQL query string + const sqlQuery = generateSQLQuery(activeFilters) + + // Use the custom hook to fetch data + const { chartData, isLoading: dataLoading, error: dataError } = useSurveyData(sqlQuery, isInView) + + // Reset animation state when filters change + useEffect(() => { + setShouldAnimateBars(false) + }, [activeFilters]) + + // Trigger bar animation when data loads + useEffect(() => { + if (!dataLoading && !filtersLoading && chartData.length > 0 && !shouldAnimateBars) { + // Small delay to ensure DOM is ready + const timer = setTimeout(() => { + setShouldAnimateBars(true) + }, 100) + + return () => clearTimeout(timer) + } + }, [dataLoading, filtersLoading, chartData.length, shouldAnimateBars]) + + const [view, setView] = useState<'chart' | 'sql'>('chart') + + const setFilterValue = (filterKey: string, value: string) => { + setActiveFilters((prev: Record) => ({ + ...prev, + [filterKey]: value, + })) + } + + const isLoading = filtersLoading || dataLoading + const error = filtersError || dataError + + // Find the maximum value for scaling + const maxValue = chartData.length > 0 ? Math.max(...chartData.map((item) => item.value)) : 0 + + // State for expand/collapse + 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 (FIXED_HEIGHT - 40px for button area) + + const skeletonData = [ + { label: 'Loading', value: 0, rawValue: 0 }, + { label: 'Loading', value: 0, rawValue: 0 }, + { label: 'Loading', value: 0, rawValue: 0 }, + ] + + const displayData = isLoading ? skeletonData : chartData || [] + + return ( +
+
+

{title}

+
+
+
+ {error ? ( +
+

Error: {error}

+
+ ) : view === 'chart' ? ( +
+ {/* Each bar as a vertical stack: label above, bar below */} +
+ {displayData.map((item, index) => ( +
+ {/* Text above the bar */} +
+ {item.label} + {item.value < 1 ? '<1%' : `${item.value}%`} +
+ + {/* Entire bar (including background) */} +
+ {/* Filled portion of the bar */} +
+
+
+ ))} +
+
+ ) : ( + + {sqlQuery} + + )} + + {/* Expand button overlay */} + {!isExpanded && !isLoading && !error && chartData.length > 3 && ( +
+ +
+ )} +
+
+ + {/* Filters and toggle */} +
+ {filters && activeFilters && setFilterValue && ( +
+ {Object.entries(filters).map(([filterKey, filterConfig]) => ( + + ))} +
+ )} + +
+
+ ) +} diff --git a/apps/www/components/SurveyResults/HeadquartersChart.tsx b/apps/www/components/SurveyResults/HeadquartersChart.tsx new file mode 100644 index 00000000000..cbb6d06f971 --- /dev/null +++ b/apps/www/components/SurveyResults/HeadquartersChart.tsx @@ -0,0 +1,39 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateHeadquartersSQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.funding_stage !== 'unset') { + whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`) + } + + if (activeFilters.startup_age !== 'unset') { + whereClauses.push(`startup_age = '${activeFilters.startup_age}'`) + } + + if (activeFilters.previous_company !== 'unset') { + // Convert string to boolean for the SQL query + const previousCompanyBool = activeFilters.previous_company === 'true' + whereClauses.push(`previous_company = ${previousCompanyBool}`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `SELECT + headquarters, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY headquarters +ORDER BY total DESC;` +} + +export function HeadquartersChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/IndustryChart.tsx b/apps/www/components/SurveyResults/IndustryChart.tsx new file mode 100644 index 00000000000..24f96e8658d --- /dev/null +++ b/apps/www/components/SurveyResults/IndustryChart.tsx @@ -0,0 +1,52 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +// Generate SQL query for team count chart +function generateIndustrySQL(activeFilters) { + const whereClauses = [] + + // Always filter out NULL values + whereClauses.push(`industry_normalized IS NOT NULL`) + + if (activeFilters.person_age !== 'unset') { + whereClauses.push(`person_age = '${activeFilters.person_age}'`) + } + if (activeFilters.funding_stage !== 'unset') { + whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`) + } + + if (activeFilters.market_model !== 'unset') { + whereClauses.push(`'${activeFilters.market_model}' = ANY(market_model)`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `WITH industry_mapping AS ( + SELECT + industry_normalized, + CASE + WHEN industry_normalized = 'Developer tools and platforms' THEN 'Dev tools' + WHEN industry_normalized = 'AI / ML tools' THEN 'AI / ML' + WHEN industry_normalized IN ('SaaS', 'Dev tools', 'AI / ML', 'Consumer', 'Education', 'eCommerce', 'Fintech', 'Healthtech') THEN industry_normalized + ELSE 'Other' + END AS industry_clean + FROM responses_2025 + ${whereClause} +) +SELECT + industry_clean AS industry_normalized, + COUNT(*) AS total +FROM industry_mapping +GROUP BY industry_clean +ORDER BY total DESC;` +} + +export function IndustryChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/InitialPayingCustomersChart.tsx b/apps/www/components/SurveyResults/InitialPayingCustomersChart.tsx new file mode 100644 index 00000000000..53ca466c6ce --- /dev/null +++ b/apps/www/components/SurveyResults/InitialPayingCustomersChart.tsx @@ -0,0 +1,44 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateInitialPayingCustomersSQL(activeFilters: Record) { + const whereClauses = [] + + // Always filter out NULL values + // whereClauses.push(`initial_paying_customers IS NOT NULL`) + // whereClauses.push(`initial_paying_customers != ''`) + + if (activeFilters.industry_normalized !== 'unset') { + whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`) + } + + if (activeFilters.market_model !== 'unset') { + whereClauses.push(`'${activeFilters.market_model}' = ANY(market_model)`) + } + + if (activeFilters.currently_monetizing !== 'unset') { + whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `SELECT + source, + COUNT(DISTINCT id) AS respondents +FROM ( + SELECT id, unnest(initial_paying_customers) AS source + FROM responses_2025${whereClause ? '\n' + whereClause : ''} +) sub +GROUP BY source +ORDER BY respondents DESC;` +} + +export function InitialPayingCustomersChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/MonetizationChart.tsx b/apps/www/components/SurveyResults/MonetizationChart.tsx new file mode 100644 index 00000000000..55ccd49858e --- /dev/null +++ b/apps/www/components/SurveyResults/MonetizationChart.tsx @@ -0,0 +1,34 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateMonetizationSQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.headquarters !== 'unset') { + whereClauses.push(`headquarters = '${activeFilters.headquarters}'`) + } + + if (activeFilters.money_raised !== 'unset') { + whereClauses.push(`money_raised = '${activeFilters.money_raised}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `SELECT + currently_monetizing, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY currently_monetizing +ORDER BY total DESC;` +} + +export function MonetizationChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/MustHaveDevToolsChart.tsx b/apps/www/components/SurveyResults/MustHaveDevToolsChart.tsx new file mode 100644 index 00000000000..be2b14a6089 --- /dev/null +++ b/apps/www/components/SurveyResults/MustHaveDevToolsChart.tsx @@ -0,0 +1,43 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateMustHaveDevToolsSQL(activeFilters: Record) { + const whereClauses = [] + + // Always filter out NULL values + whereClauses.push(`must_have_dev_tools IS NOT NULL`) + whereClauses.push(`must_have_dev_tools != ''`) + + if (activeFilters.currently_monetizing !== 'unset') { + whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`) + } + + if (activeFilters.money_raised !== 'unset') { + whereClauses.push(`money_raised = '${activeFilters.money_raised}'`) + } + + if (activeFilters.backend_stack !== 'unset') { + whereClauses.push(`'${activeFilters.backend_stack}' = ANY(backend_stack)`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return ` +SELECT + must_have_dev_tools, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY must_have_dev_tools +ORDER BY total DESC +LIMIT 100;` +} + +export function MustHaveDevToolsChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/NewIdeasChart.tsx b/apps/www/components/SurveyResults/NewIdeasChart.tsx new file mode 100644 index 00000000000..83a2fe22cac --- /dev/null +++ b/apps/www/components/SurveyResults/NewIdeasChart.tsx @@ -0,0 +1,44 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateNewIdeasSQL(activeFilters: Record) { + const whereClauses = [] + + // Always filter out NULL values + whereClauses.push(`must_have_dev_tools IS NOT NULL`) + whereClauses.push(`must_have_dev_tools != ''`) + + if (activeFilters.person_age !== 'unset') { + whereClauses.push(`person_age = '${activeFilters.person_age}'`) + } + + if (activeFilters.role !== 'unset') { + whereClauses.push(`role = '${activeFilters.role}'`) + } + + if (activeFilters.industry_normalized !== 'unset') { + whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `SELECT + avenue, + COUNT(DISTINCT id) AS respondents +FROM ( + SELECT id, unnest(new_ideas) AS avenue + FROM responses_2025${whereClause ? '\n' + whereClause : ''} +) sub +GROUP BY avenue +ORDER BY respondents DESC;` +} + +export function NewIdeasChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/NewslettersPaidForChart.tsx b/apps/www/components/SurveyResults/NewslettersPaidForChart.tsx new file mode 100644 index 00000000000..09c04148a38 --- /dev/null +++ b/apps/www/components/SurveyResults/NewslettersPaidForChart.tsx @@ -0,0 +1,45 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateNewslettersPaidForSQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.headquarters !== 'unset') { + whereClauses.push(`headquarters = '${activeFilters.headquarters}'`) + } + + if (activeFilters.industry_normalized !== 'unset') { + whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`) + } + + if (activeFilters.currently_monetizing !== 'unset') { + whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return ` +SELECT + unnest(newsletters_paid_for) AS technology, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY technology +UNION ALL +SELECT + 'None of the above' AS technology, + COUNT(*) AS total +FROM responses_2025 +WHERE newsletters_paid_for = '{}' +ORDER BY total DESC; +` +} + +export function NewslettersPaidForChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/ObservabilityChart.tsx b/apps/www/components/SurveyResults/ObservabilityChart.tsx new file mode 100644 index 00000000000..96dbeffe8b2 --- /dev/null +++ b/apps/www/components/SurveyResults/ObservabilityChart.tsx @@ -0,0 +1,38 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateObservabilitySQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.currently_monetizing !== 'unset') { + whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`) + } + + if (activeFilters.money_raised !== 'unset') { + whereClauses.push(`money_raised = '${activeFilters.money_raised}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `WITH expanded AS ( + SELECT unnest(observability) AS technology +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +) +SELECT + technology, + COUNT(*) AS total +FROM expanded +WHERE technology NOT IN ('We don''t use observability tools yet', 'Not yet') +GROUP BY technology +ORDER BY total DESC;` +} + +export function ObservabilityChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/OpenTelemetryChart.tsx b/apps/www/components/SurveyResults/OpenTelemetryChart.tsx new file mode 100644 index 00000000000..0d77917cf00 --- /dev/null +++ b/apps/www/components/SurveyResults/OpenTelemetryChart.tsx @@ -0,0 +1,39 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateOpenTelemetrySQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.currently_monetizing !== 'unset') { + whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`) + } + + if (activeFilters.money_raised !== 'unset') { + whereClauses.push(`money_raised = '${activeFilters.money_raised}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `SELECT + use_opentelemetry, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY use_opentelemetry +ORDER BY + CASE use_opentelemetry + WHEN 'Yes' THEN 1 + WHEN 'Not yet, but planning to' THEN 2 + WHEN 'No' THEN 3 +END;` +} + +export function OpenTelemetryChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/PivotFreqChart.tsx b/apps/www/components/SurveyResults/PivotFreqChart.tsx new file mode 100644 index 00000000000..0eb7d688ae4 --- /dev/null +++ b/apps/www/components/SurveyResults/PivotFreqChart.tsx @@ -0,0 +1,50 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generatePivotFreqSQL(activeFilters: Record) { + const whereClauses = [] + + // if (activeFilters.headquarters !== 'unset') { + // whereClauses.push(`headquarters = '${activeFilters.headquarters}'`) + // } + + // if (activeFilters.currently_monetizing !== 'unset') { + // whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`) + // } + + if (activeFilters.person_age !== 'unset') { + whereClauses.push(`person_age = '${activeFilters.person_age}'`) + } + + if (activeFilters.previous_company !== 'unset') { + // Convert string to boolean for the SQL query + const previousCompanyBool = activeFilters.previous_company === 'true' + whereClauses.push(`previous_company = ${previousCompanyBool}`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `SELECT + pivots_before_current, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY pivots_before_current +ORDER BY + CASE pivots_before_current + WHEN 'Never' THEN 1 + WHEN 'Once' THEN 2 + WHEN 'Twice' THEN 3 + WHEN 'More than twice' THEN 4 + END;` +} + +export function PivotFreqChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/PodcastsListenedToChart.tsx b/apps/www/components/SurveyResults/PodcastsListenedToChart.tsx new file mode 100644 index 00000000000..6eec7ca173c --- /dev/null +++ b/apps/www/components/SurveyResults/PodcastsListenedToChart.tsx @@ -0,0 +1,46 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generatePodcastsListenedToSQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.headquarters !== 'unset') { + whereClauses.push(`headquarters = '${activeFilters.headquarters}'`) + } + + if (activeFilters.industry_normalized !== 'unset') { + whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`) + } + + if (activeFilters.currently_monetizing !== 'unset') { + whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `SELECT technology, COUNT(*) AS total +FROM ( + SELECT + CASE + WHEN podcasts_listened_to = '{}' THEN 'None' + WHEN podcast = 'I don''t listen to podcasts regularly' THEN 'None' + ELSE podcast + END AS technology + FROM responses_2025 + CROSS JOIN LATERAL unnest( + CASE WHEN podcasts_listened_to = '{}' THEN ARRAY['None'] ELSE podcasts_listened_to END + ) AS podcast${whereClause ? '\n' + whereClause : ''} +) t +GROUP BY technology +ORDER BY total DESC;` +} + +export function PodcastsListenedToChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/PreviousCompanyChart.tsx b/apps/www/components/SurveyResults/PreviousCompanyChart.tsx new file mode 100644 index 00000000000..50a86e7eda9 --- /dev/null +++ b/apps/www/components/SurveyResults/PreviousCompanyChart.tsx @@ -0,0 +1,42 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generatePreviousCompanySQL(activeFilters: Record) { + const whereClauses = [] + + // Always filter out NULL values + whereClauses.push(`previous_company IS NOT NULL`) + + if (activeFilters.person_age !== 'unset') { + whereClauses.push(`person_age = '${activeFilters.person_age}'`) + } + + if (activeFilters.money_raised !== 'unset') { + whereClauses.push(`money_raised = '${activeFilters.money_raised}'`) + } + + const whereClause = `WHERE ${whereClauses.join('\n AND ')}` + + return `SELECT + CASE + WHEN previous_company = true THEN 'Yes' + WHEN previous_company = false THEN 'No' + -- ELSE 'Unknown' + END AS previous_company, + COUNT(*) AS total +FROM responses_2025 +${whereClause} +GROUP BY previous_company +ORDER BY total DESC;` +} + +export function PreviousCompanyChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/PricingChart.tsx b/apps/www/components/SurveyResults/PricingChart.tsx new file mode 100644 index 00000000000..f6d38151052 --- /dev/null +++ b/apps/www/components/SurveyResults/PricingChart.tsx @@ -0,0 +1,31 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generatePricingSQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.team_count !== 'unset') { + whereClauses.push(`team_count = '${activeFilters.team_count}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return ` + SELECT + unnest(pricing) AS pricing_model, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY pricing_model +ORDER BY total DESC; +` +} + +export function PricingChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/ProblemBeingSolvedChart.tsx b/apps/www/components/SurveyResults/ProblemBeingSolvedChart.tsx new file mode 100644 index 00000000000..9c62630645b --- /dev/null +++ b/apps/www/components/SurveyResults/ProblemBeingSolvedChart.tsx @@ -0,0 +1,43 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateProblemBeingSolvedSQL(activeFilters: Record) { + const whereClauses = [] + + // Always filter out NULL values + whereClauses.push(`problem_solving IS NOT NULL`) + whereClauses.push(`problem_solving != ''`) + + if (activeFilters.currently_monetizing !== 'unset') { + whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`) + } + + if (activeFilters.money_raised !== 'unset') { + whereClauses.push(`money_raised = '${activeFilters.money_raised}'`) + } + + if (activeFilters.backend_stack !== 'unset') { + whereClauses.push(`'${activeFilters.backend_stack}' = ANY(backend_stack)`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return ` +SELECT + problem_solving, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY problem_solving +ORDER BY total DESC +LIMIT 100;` +} + +export function ProblemBeingSolvedChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/RechartsChartWithQuery.tsx b/apps/www/components/SurveyResults/RechartsChartWithQuery.tsx new file mode 100644 index 00000000000..8154c2eebc9 --- /dev/null +++ b/apps/www/components/SurveyResults/RechartsChartWithQuery.tsx @@ -0,0 +1,349 @@ +'use client' +import { useEffect, useState } from 'react' +import { createClient } from '@supabase/supabase-js' + +import { + Bar, + BarChart, + PieChart, + Pie, + CartesianGrid, + LabelList, + XAxis, + YAxis, + Cell, +} from 'recharts' + +import { Card, CardContent, CardHeader, CardTitle } from 'ui' +import CodeBlock from '~/components/CodeBlock/CodeBlock' +import type { LANG } from '~/components/CodeBlock/CodeBlock' +import { ChartConfig, ChartContainer } from 'ui' +import { SurveyCodeWindow } from './SurveyCodeWindow' +import { FilterDropdown } from './FilterDropdown' + +const chartConfig = { + value: { + label: 'Value', + color: 'hsl(var(--brand-default))', + }, + label: { + color: '#ff0000', + }, +} satisfies ChartConfig + +// Create a separate Supabase client for your external project +const externalSupabase = createClient( + process.env.NEXT_PUBLIC_SURVEY_SUPABASE_URL, + process.env.NEXT_PUBLIC_SURVEY_SUPABASE_ANON_KEY +) + +// Custom hook to fetch filter options from Supabase +function useFilterOptions(filterColumns: string[]) { + const [filters, setFilters] = useState({}) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + async function fetchFilterOptions() { + try { + setIsLoading(true) + setError(null) + + console.log('Fetching filter options from Supabase for columns:', filterColumns) + + const filterOptions = {} + + for (const column of filterColumns) { + const { data, error: fetchError } = await externalSupabase + .from('responses_2025') + .select(column) + .not(column, 'is', null) + // Remove this line for boolean columns - it excludes false values + // .not(column, 'eq', '') + + if (fetchError) { + console.error(`Error fetching ${column} options:`, fetchError) + continue + } + + console.log(`Raw data for ${column}:`, data) + + // Extract all individual values from PostgreSQL arrays + const allValues = data.flatMap((row) => { + const value = row[column] + console.log( + `Processing value for ${column}:`, + value, + typeof value, + Array.isArray(value) + ) + + if (Array.isArray(value)) { + // Handle PostgreSQL arrays + return value.filter((item) => item && item.length > 0) + } else if (typeof value === 'string' && value.startsWith('{') && value.endsWith('}')) { + // Handle PostgreSQL array format like {Firebase,MongoDB,MySQL} + const innerContent = value.slice(1, -1) // Remove { and } + if (innerContent.trim() === '') { + return [] + } + return innerContent + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0) + } + return [value] + }) + + console.log(`All values for ${column}:`, allValues) + + // Get unique values and sort them + const uniqueValues = [...new Set(allValues)].sort() + + console.log(`Unique values for ${column}:`, uniqueValues) + + filterOptions[column] = { + label: column.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase()), + options: [ + { + value: 'unset', + label: `All ${column.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase())}s`, + }, + ...uniqueValues.map((value) => ({ + value: String(value), // Convert to string for React + label: String(value), // Convert to string for display + })), + ], + } + } + + console.log('Final filter options:', filterOptions) + setFilters(filterOptions) + } catch (err) { + console.error('Error fetching filter options:', err) + setError(err.message) + } finally { + setIsLoading(false) + } + } + + fetchFilterOptions() + }, [filterColumns]) + + return { filters, isLoading, error } +} + +// Custom hook to fetch survey data using SQL query via RPC +// TODO: switch out to use supabase-js instead +function useSurveyData(sqlQuery: string) { + const [chartData, setChartData] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + async function fetchData() { + try { + setIsLoading(true) + setError(null) + + console.log('Executing SQL query:', sqlQuery) + + // Execute the SQL query using Supabase RPC + const { data, error: fetchError } = await externalSupabase.rpc('execute_sql', { + query: sqlQuery, + }) + + if (fetchError) { + console.error('Error executing SQL query:', fetchError) + setError(fetchError.message) + return + } + + console.log('Raw data from SQL query:', data) + + // Calculate total for percentage calculation + const total = data.reduce((sum, row) => sum + parseInt(row.total), 0) + + // Transform the data to match chart format + const processedData = data.map((row) => ({ + label: row.label || row.value || row[Object.keys(row)[0]], // Get the first column as label + value: total > 0 ? parseFloat(((parseInt(row.total) / total) * 100).toFixed(1)) : 0, + })) + + console.log('Processed chart data:', processedData) + setChartData(processedData) + } catch (err) { + console.error('Error in fetchData:', err) + setError(err.message) + } finally { + setIsLoading(false) + } + } + + fetchData() + }, [sqlQuery]) + + return { chartData, isLoading, error } +} + +export function RechartsChartWithQuery({ + title, + targetColumn, + filterColumns, + generateSQLQuery, + chartType = 'bar', // TODO: Could be 'bar', 'pie', etc. +}) { + // Get dynamic filter options + const { + filters, + isLoading: filtersLoading, + error: filtersError, + } = useFilterOptions(filterColumns) + + // Start with all filters unset (showing "all") + const [activeFilters, setActiveFilters] = useState( + filterColumns.reduce((acc, col) => ({ ...acc, [col]: 'unset' }), {}) + ) + + // Generate the SQL query string + const sqlQuery = generateSQLQuery(activeFilters) + + // Use the custom hook to fetch data + const { chartData, isLoading: dataLoading, error: dataError } = useSurveyData(sqlQuery) + + const setFilterValue = (filterKey, value) => { + setActiveFilters((prev) => ({ + ...prev, + [filterKey]: value, + })) + } + + const isLoading = filtersLoading || dataLoading + const error = filtersError || dataError + + // Two different color arrays + // One for gradation (e.g. "Yes", "Maybe", "No") + const COLORS = [ + 'hsl(var(--brand-default))', + 'hsl(var(--brand-500))', + 'hsl(var(--foreground-light))', + ] + // One for a clear binary answer (e.g. "Yes", "No") + const COLORS_BINARY = ['hsl(var(--brand-default))', 'hsl(var(--foreground-muted))'] + + return ( + + + {title} + + + {isLoading ? ( +
+
Loading data...
+
+ ) : error ? ( +
+
Error: {error}
+
+ ) : ( + + {chartType === 'bar' && ( + + + + + + {chartData.map((entry, index) => ( + + ))} + `${value}%`} + /> + + + )} + {chartType === 'pie' && ( + + + {chartData.map((entry, index) => ( + + ))} + + + )} + + )} +
+ {/* */} +
+ {filters && activeFilters && setFilterValue && ( +
+ {Object.entries(filters).map(([filterKey, filterConfig]) => ( + + ))} +
+ )} + + {/* + {sqlQuery} + */} +
+
+ ) +} diff --git a/apps/www/components/SurveyResults/RegularSocialMediaUseChart.tsx b/apps/www/components/SurveyResults/RegularSocialMediaUseChart.tsx new file mode 100644 index 00000000000..346c1b81a6d --- /dev/null +++ b/apps/www/components/SurveyResults/RegularSocialMediaUseChart.tsx @@ -0,0 +1,54 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateRegularSocialMediaUseSQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.role !== 'unset') { + whereClauses.push(`role = '${activeFilters.role}'`) + } + + if (activeFilters.industry_normalized !== 'unset') { + whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`) + } + + if (activeFilters.person_age !== 'unset') { + whereClauses.push(`person_age = '${activeFilters.person_age}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `WITH founders AS ( + SELECT id, regular_social_media_use + FROM responses_2025 + WHERE role = 'Founder / Co-founder' +) +SELECT + platform AS label, + COUNT(*) AS total +FROM ( + SELECT id, unnest(regular_social_media_use) AS platform + FROM founders +) t +GROUP BY platform +ORDER BY total DESC; +` +} + +// SELECT +// unnest(regular_social_media_use) AS social_media, +// COUNT(*) AS total +// FROM responses_2025${whereClause ? '\n' + whereClause : ''} +// GROUP BY social_media +// ORDER BY total DESC; + +export function RegularSocialMediaUseChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/RoleChart.tsx b/apps/www/components/SurveyResults/RoleChart.tsx new file mode 100644 index 00000000000..8015ca5b381 --- /dev/null +++ b/apps/www/components/SurveyResults/RoleChart.tsx @@ -0,0 +1,52 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +// Generate SQL query for team count chart +function generateRoleSQL(activeFilters) { + const whereClauses = [] + + if (activeFilters.person_age !== 'unset') { + whereClauses.push(`person_age = '${activeFilters.person_age}'`) + } + if (activeFilters.funding_stage !== 'unset') { + whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`) + } + if (activeFilters.headquarters !== 'unset') { + whereClauses.push(`headquarters = '${activeFilters.headquarters}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `SELECT + CASE + WHEN role = 'Founder / Co-founder' THEN 'Founder' + WHEN role IN ('Engineer', 'Founder / Co-founder') THEN role + ELSE 'Other' + END AS role, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY CASE + WHEN role = 'Founder / Co-founder' THEN 'Founder' + WHEN role IN ('Engineer', 'Founder / Co-founder') THEN role + ELSE 'Other' + END +ORDER BY total DESC; +` +} + +// SELECT +// person_role_normalized, +// COUNT(*) AS total +// FROM responses_2025${whereClause ? '\n' + whereClause : ''} +// GROUP BY person_role_normalized +// ORDER BY total DESC + +export function RoleChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/SalesToolsChart.tsx b/apps/www/components/SurveyResults/SalesToolsChart.tsx new file mode 100644 index 00000000000..8e03079b91f --- /dev/null +++ b/apps/www/components/SurveyResults/SalesToolsChart.tsx @@ -0,0 +1,39 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateSalesToolsSQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.team_count !== 'unset') { + whereClauses.push(`team_count = '${activeFilters.team_count}'`) + } + + if (activeFilters.funding_stage !== 'unset') { + whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`) + } + + if (activeFilters.first_sales_hire_stage !== 'unset') { + whereClauses.push(`first_sales_hire_stage = '${activeFilters.first_sales_hire_stage}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return ` + SELECT + unnest(sales_tools) AS tool_name, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY tool_name +ORDER BY total DESC; +` +} + +export function SalesToolsChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/SubscriptionsChart.tsx b/apps/www/components/SurveyResults/SubscriptionsChart.tsx new file mode 100644 index 00000000000..1b1456b8a13 --- /dev/null +++ b/apps/www/components/SurveyResults/SubscriptionsChart.tsx @@ -0,0 +1,45 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateSubscriptionsSQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.headquarters !== 'unset') { + whereClauses.push(`headquarters = '${activeFilters.headquarters}'`) + } + + if (activeFilters.industry_normalized !== 'unset') { + whereClauses.push(`industry_normalized = '${activeFilters.industry_normalized}'`) + } + + if (activeFilters.currently_monetizing !== 'unset') { + whereClauses.push(`currently_monetizing = '${activeFilters.currently_monetizing}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return ` +SELECT + unnest(subscriptions) AS technology, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY technology +UNION ALL +SELECT + 'None of the above' AS technology, + COUNT(*) AS total +FROM responses_2025 +WHERE subscriptions = '{}' +ORDER BY total DESC; +` +} + +export function SubscriptionsChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/SurveyChapter.tsx b/apps/www/components/SurveyResults/SurveyChapter.tsx new file mode 100644 index 00000000000..f4c2d90074d --- /dev/null +++ b/apps/www/components/SurveyResults/SurveyChapter.tsx @@ -0,0 +1,48 @@ +import { SurveyPullQuote } from './SurveyPullQuote' + +interface SurveyChapterProps { + number: number + title: string + description: string + pullQuote?: { + quote: string + author: string + authorPosition: string + authorAvatar: string + } + children: React.ReactNode + totalChapters?: number +} + +export function SurveyChapter({ + number, + title, + description, + pullQuote, + children, + totalChapters, +}: SurveyChapterProps) { + return ( +
+
+
+

{title}

+

{description}

+
+
{children}
+
+ + {pullQuote && ( + + )} +
+ ) +} diff --git a/apps/www/components/SurveyResults/SurveyChapterSection.tsx b/apps/www/components/SurveyResults/SurveyChapterSection.tsx new file mode 100644 index 00000000000..348297a9b50 --- /dev/null +++ b/apps/www/components/SurveyResults/SurveyChapterSection.tsx @@ -0,0 +1,143 @@ +// import SectionContainer from '~/components/Layouts/SectionContainer' +import { SurveyStatCard } from './SurveyStatCard' +import { SurveyPullQuote } from './SurveyPullQuote' +import { SurveyWordCloud } from './SurveyWordCloud' +import { SurveySummarizedAnswer } from './SurveySummarizedAnswer' +import { SurveyRankedAnswersPair } from './SurveyRankedAnswersPair' + +// Charts (TODO: delete unused) +import { AcceleratorParticipationChart } from './AcceleratorParticipationChart' +import { RoleChart } from './RoleChart' +import { IndustryChart } from './IndustryChart' +import { FundingStageChart } from './FundingStageChart' +import { DatabasesChart } from './DatabasesChart' +import { AIModelsChart } from './AIModelsChart' +// import { TeamCountChart } from '~/components/SurveyResults/TeamCountChart' +// import { TechnicalFoundersChart } from '~/components/SurveyResults/TechnicalFoundersChart' +// import { PreviousCompanyChart } from '~/components/SurveyResults/PreviousCompanyChart' +// import { FundingStageChart } from '~/components/SurveyResults/FundingStageChart' +import { HeadquartersChart } from '~/components/SurveyResults/HeadquartersChart' +// import { SurveyPullQuote } from '~/components/SurveyResults/SurveyPullQuote' +// import { SurveyStatCard } from '~/components/SurveyResults/SurveyStatCard' +// import { RoleChart } from '~/components/SurveyResults/RoleChart' +// import { ProblemBeingSolvedChart } from '~/components/SurveyResults/ProblemBeingSolvedChart' +// import { PersonAgeChart } from '~/components/SurveyResults/AgeChart' +// import { MonetizationChart } from '~/components/SurveyResults/MonetizationChart' +// import { AcceleratorParticipationChart } from '~/components/SurveyResults/AcceleratorParticipationChart' +// import { PivotFreqChart } from '~/components/SurveyResults/PivotFreqChart' +// import { FrontendStackChart } from '~/components/SurveyResults/FrontendStackChart' +// import { BackendStackChart } from '~/components/SurveyResults/BackendStackChart' +import { SalesToolsChart } from '~/components/SurveyResults/SalesToolsChart' +// import { PricingChart } from '~/components/SurveyResults/PricingChart' +// import { IndustryChart } from '~/components/SurveyResults/IndustryChart' +// import { DataWarehousesChart } from '~/components/SurveyResults/DataWarehousesChart' +// import { VectorDatabasesChart } from '~/components/SurveyResults/VectorDatabasesChart' +// import { OpenTelemetryChart } from '~/components/SurveyResults/OpenTelemetryChart' +// import { ObservabilityChart } from '~/components/SurveyResults/ObservabilityChart' +// import { CloudProvidersChart } from '~/components/SurveyResults/CloudProvidersChart' +// import { MustHaveDevToolsChart } from '~/components/SurveyResults/MustHaveDevToolsChart' +import { AICodingToolsChart } from './AICodingToolsChart' +// import { SubscriptionsChart } from '~/components/SurveyResults/SubscriptionsChart' +// import { NewslettersPaidForChart } from '~/components/SurveyResults/NewslettersPaidForChart' +// import { PodcastsListenedToChart } from '~/components/SurveyResults/PodcastsListenedToChart' +import { RegularSocialMediaUseChart } from '~/components/SurveyResults/RegularSocialMediaUseChart' +import { NewIdeasChart } from '~/components/SurveyResults/NewIdeasChart' +import { InitialPayingCustomersChart } from '~/components/SurveyResults/InitialPayingCustomersChart' +import { WorldOutlookChart } from '~/components/SurveyResults/WorldOutlookChart' +import { BiggestChallengeChart } from '~/components/SurveyResults/BiggestChallengeChart' + +interface SurveyChapterSectionProps { + number: string + title: string + description: string + stats?: Array<{ number: number; unit: string; label: string }> + charts?: string[] + pullQuote?: { + quote: string + author: string + authorPosition: string + authorAvatar: 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({ + number, + title, + description, + stats, + charts, + pullQuote, + wordCloud, + summarizedAnswer, + rankedAnswersPair, + children, +}: SurveyChapterSectionProps) { + const chartComponents = { + RoleChart, + IndustryChart, + FundingStageChart, + AcceleratorParticipationChart, + DatabasesChart, + AICodingToolsChart, + AIModelsChart, + RegularSocialMediaUseChart, + NewIdeasChart, + InitialPayingCustomersChart, + SalesToolsChart, + WorldOutlookChart, + BiggestChallengeChart, + HeadquartersChart, + } + + 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/components/SurveyResults/SurveyCodeWindow.tsx b/apps/www/components/SurveyResults/SurveyCodeWindow.tsx new file mode 100644 index 00000000000..054ebe9e790 --- /dev/null +++ b/apps/www/components/SurveyResults/SurveyCodeWindow.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { cn } from 'ui' +import CodeBlock from '~/components/CodeBlock/CodeBlock' +import type { LANG } from '~/components/CodeBlock/CodeBlock' +import { FilterDropdown } from './FilterDropdown' + +interface Props { + code: any + lang?: LANG + className?: string + style?: React.CSSProperties + showLineNumbers?: boolean + filters?: Record + activeFilters?: Record + setFilterValue?: (filterKey: string, value: string) => void +} + +export const SurveyCodeWindow = ({ + code, + lang, + style, + className, + showLineNumbers, + filters, + activeFilters, + setFilterValue, +}: Props) => { + return ( + + {code} + + ) +} diff --git a/apps/www/components/SurveyResults/SurveyPullQuote.tsx b/apps/www/components/SurveyResults/SurveyPullQuote.tsx new file mode 100644 index 00000000000..8a89a2f12d2 --- /dev/null +++ b/apps/www/components/SurveyResults/SurveyPullQuote.tsx @@ -0,0 +1,32 @@ +import Image from 'next/image' + +export function SurveyPullQuote({ + quote, + author, + authorPosition, + authorAvatar, +}: { + quote: string + author: string + authorPosition: string + authorAvatar: string +}) { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/SurveyRankedAnswersPair.tsx b/apps/www/components/SurveyResults/SurveyRankedAnswersPair.tsx new file mode 100644 index 00000000000..4b2073ed081 --- /dev/null +++ b/apps/www/components/SurveyResults/SurveyRankedAnswersPair.tsx @@ -0,0 +1,23 @@ +export function SurveyRankedAnswersPair({ + rankedAnswersPair, +}: { + rankedAnswersPair: Array<{ label: string; answers: string[] }> +}) { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/SurveyStatCard.tsx b/apps/www/components/SurveyResults/SurveyStatCard.tsx new file mode 100644 index 00000000000..b475e18a243 --- /dev/null +++ b/apps/www/components/SurveyResults/SurveyStatCard.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from 'react' + +export function SurveyStatCard({ + unit = '%', + label, + progressValue, + maxValue = 100, +}: { + unit?: string + label: string + progressValue?: number + maxValue?: number +}) { + const [displayValue, setDisplayValue] = useState(0) + const [hasAnimated, setHasAnimated] = useState(false) + const [shouldAnimateBar, setShouldAnimateBar] = useState(false) + const cardRef = useRef(null) + + useEffect(() => { + if (!progressValue || hasAnimated) return + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !hasAnimated) { + setHasAnimated(true) + setShouldAnimateBar(true) + + // Start counting animation + const startTime = Date.now() + const duration = 600 + const startValue = 0 + const endValue = progressValue + + const animate = () => { + const elapsed = Date.now() - startTime + const progress = Math.min(elapsed / duration, 1) + + // Easing function for smooth animation + const easeOutQuart = 1 - Math.pow(1 - progress, 4) + const currentValue = Math.round(startValue + (endValue - startValue) * easeOutQuart) + + setDisplayValue(currentValue) + + if (progress < 1) { + requestAnimationFrame(animate) + } + } + + requestAnimationFrame(animate) + } + }) + }, + { threshold: 0.1 } + ) + + if (cardRef.current) { + observer.observe(cardRef.current) + } + + return () => { + if (cardRef.current) { + observer.unobserve(cardRef.current) + } + } + }, [progressValue, hasAnimated]) + + // Format display value to always show two digits + const formattedValue = displayValue.toString().padStart(2, '0') + + return ( +
+ {/* Progress bar */} + {progressValue !== undefined && ( +
+
+
+ )} +
+

+ {formattedValue} + {unit} +

+

{label}

+
+
+ ) +} diff --git a/apps/www/components/SurveyResults/SurveySummarizedAnswer.tsx b/apps/www/components/SurveyResults/SurveySummarizedAnswer.tsx new file mode 100644 index 00000000000..55b60d62c70 --- /dev/null +++ b/apps/www/components/SurveyResults/SurveySummarizedAnswer.tsx @@ -0,0 +1,35 @@ +import { useState, useEffect } from 'react' + +export function SurveySummarizedAnswer({ label, answers }: { label: string; answers: string[] }) { + const [currentIndex, setCurrentIndex] = useState(0) + + useEffect(() => { + if (answers.length <= 1) return + + const interval = setInterval(() => { + setCurrentIndex((prev) => (prev + 1) % answers.length) + }, 3000) + + return () => clearInterval(interval) + }, [answers.length]) + + if (answers.length <= 1) { + return ( +
+

{answers[0]}

+

{label}

+
+ ) + } + + return ( +
+
+

+ {answers[currentIndex]} +

+
+

{label}

+
+ ) +} diff --git a/apps/www/components/SurveyResults/SurveyWordCloud.tsx b/apps/www/components/SurveyResults/SurveyWordCloud.tsx new file mode 100644 index 00000000000..55933f610c7 --- /dev/null +++ b/apps/www/components/SurveyResults/SurveyWordCloud.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from 'react' + +export function SurveyWordCloud({ + answers, + label, +}: { + answers: { text: string; count: number }[] + label: string +}) { + const [currentItems, setCurrentItems] = useState<{ text: string; count: number }[]>([]) + const [isRotating, setIsRotating] = useState(false) + const [scramblingTexts, setScramblingTexts] = useState([]) + + // Calculate the range within the current context + const counts = answers.map((answer) => answer.count) + const minCount = Math.min(...counts) + const maxCount = Math.max(...counts) + const range = maxCount - minCount + + // Initialize with first 12 items + useEffect(() => { + const initialItems = answers.slice(0, 12) + setCurrentItems(initialItems) + setScramblingTexts(initialItems.map((item) => item.text)) + }, [answers]) + + // Start rotation after 3 seconds + useEffect(() => { + if (answers.length <= 12) return // No need to rotate if we have 12 or fewer items + + const timer = setTimeout(() => { + setIsRotating(true) + }, 3000) + + return () => clearTimeout(timer) + }, [answers.length]) + + // Airport board scramble effect + useEffect(() => { + if (!isRotating || answers.length <= 12 || scramblingTexts.length === 0) return + + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + const characterDelay = 50 // 50ms between each character change + + const rotateItems = () => { + const newItems = currentItems.map((item, index) => { + // Calculate next item index with wrapping + const nextItemIndex = (index + Math.floor(Date.now() / 3000)) % answers.length + return answers[nextItemIndex] + }) + + setCurrentItems(newItems) + + // Start scramble animation for each item + newItems.forEach((newItem, index) => { + const oldText = scramblingTexts[index] || '' + const newText = newItem.text + const maxLength = Math.max(oldText.length, newText.length) + + for (let charIndex = 0; charIndex < maxLength; charIndex++) { + setTimeout(() => { + const newChar = newText[charIndex] + + // If we're past the old text length, just show the new char + if (charIndex >= oldText.length) { + if (newChar) { + // Only animate if there's actually a character to show + setScramblingTexts((prev) => { + const updated = [...prev] + updated[index] = (updated[index] || '').substring(0, charIndex) + newChar + return updated + }) + } + return + } + + // If we're past the new text length, remove the character + if (charIndex >= newText.length) { + setScramblingTexts((prev) => { + const updated = [...prev] + updated[index] = (updated[index] || '').substring(0, charIndex) + return updated + }) + return + } + + // Scramble through alphabet for existing characters that are being replaced + let scrambleCount = 0 + const scrambleInterval = setInterval(() => { + const randomChar = alphabet[Math.floor(Math.random() * alphabet.length)] + + setScramblingTexts((prev) => { + const updated = [...prev] + const currentText = updated[index] || '' + updated[index] = + currentText.substring(0, charIndex) + + randomChar + + currentText.substring(charIndex + 1) + return updated + }) + + scrambleCount++ + if (scrambleCount >= 8) { + // Scramble 8 times before settling + clearInterval(scrambleInterval) + setScramblingTexts((prev) => { + const updated = [...prev] + const currentText = updated[index] || '' + updated[index] = + currentText.substring(0, charIndex) + + newChar + + currentText.substring(charIndex + 1) + return updated + }) + } + }, characterDelay) + }, charIndex * 100) // Stagger each character + } + }) + } + + // Rotate every 3 seconds + const interval = setInterval(rotateItems, 3000) + + return () => clearInterval(interval) + }, [isRotating, answers, scramblingTexts, currentItems]) + + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/TeamCountChart.tsx b/apps/www/components/SurveyResults/TeamCountChart.tsx new file mode 100644 index 00000000000..08f07ae9696 --- /dev/null +++ b/apps/www/components/SurveyResults/TeamCountChart.tsx @@ -0,0 +1,45 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +// Generate SQL query for team count chart +function generateTeamCountSQL(activeFilters) { + const whereClauses = [] + + if (activeFilters.headquarters !== 'unset') { + whereClauses.push(`headquarters = '${activeFilters.headquarters}'`) + } + + if (activeFilters.funding_stage !== 'unset') { + whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`) + } + + if (activeFilters.person_age !== 'unset') { + whereClauses.push(`person_age = '${activeFilters.person_age}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `SELECT + team_count, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY team_count +ORDER BY + CASE team_count + WHEN '1-10' THEN 1 + WHEN '11-50' THEN 2 + WHEN '51-100' THEN 3 + WHEN '101-250' THEN 4 + WHEN '250' THEN 5 + END;` +} + +export function TeamCountChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/TechnicalFoundersChart.tsx b/apps/www/components/SurveyResults/TechnicalFoundersChart.tsx new file mode 100644 index 00000000000..b5e7c2ef6d8 --- /dev/null +++ b/apps/www/components/SurveyResults/TechnicalFoundersChart.tsx @@ -0,0 +1,42 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateTechnicalFoundersSQL(activeFilters: Record) { + const whereClauses = [] + + // Always filter out NULL values + whereClauses.push(`founders_are_technical IS NOT NULL`) + + if (activeFilters.person_age !== 'unset') { + whereClauses.push(`person_age = '${activeFilters.person_age}'`) + } + + if (activeFilters.money_raised !== 'unset') { + whereClauses.push(`money_raised = '${activeFilters.money_raised}'`) + } + + const whereClause = `WHERE ${whereClauses.join('\n AND ')}` + + return `SELECT + CASE + WHEN founders_are_technical = true THEN 'Yes' + WHEN founders_are_technical = false THEN 'No' + -- ELSE 'Unknown' + END AS founders_are_technical, + COUNT(*) AS total +FROM responses_2025 +${whereClause} +GROUP BY founders_are_technical +ORDER BY total DESC;` +} + +export function TechnicalFoundersChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/VectorDatabasesChart.tsx b/apps/www/components/SurveyResults/VectorDatabasesChart.tsx new file mode 100644 index 00000000000..42036a84c1e --- /dev/null +++ b/apps/www/components/SurveyResults/VectorDatabasesChart.tsx @@ -0,0 +1,32 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +function generateVectorDatabasesSQL(activeFilters: Record) { + const whereClauses = [] + + if (activeFilters.databases !== 'unset') { + whereClauses.push(`'${activeFilters.databases}' = ANY(databases)`) + } + + // TODO: Returns actual row values (e.g. "Weweb,Svelte,React") instead of just "Svelte" + // FilterDropdown should be able to handle these as separate, unique values + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `SELECT + unnest(vector_databases) AS technology, + COUNT(*) AS total +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY technology +ORDER BY total DESC; +` +} + +export function VectorDatabasesChart() { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/WorldOutlookChart.tsx b/apps/www/components/SurveyResults/WorldOutlookChart.tsx new file mode 100644 index 00000000000..8aa35b98e17 --- /dev/null +++ b/apps/www/components/SurveyResults/WorldOutlookChart.tsx @@ -0,0 +1,39 @@ +import { GenericChartWithQuery } from './GenericChartWithQuery' + +// Generate SQL query for team count chart +function generateWorldOutlookSQL(activeFilters) { + const whereClauses = [] + + if (activeFilters.role !== 'unset') { + whereClauses.push(`role = '${activeFilters.role}'`) + } + + if (activeFilters.funding_stage !== 'unset') { + whereClauses.push(`funding_stage = '${activeFilters.funding_stage}'`) + } + + if (activeFilters.headquarters !== 'unset') { + whereClauses.push(`headquarters = '${activeFilters.headquarters}'`) + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' + + return `SELECT + world_outlook, + COUNT(*) AS total + --ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 2) AS percentage +FROM responses_2025${whereClause ? '\n' + whereClause : ''} +GROUP BY world_outlook +ORDER BY total DESC;` +} + +export function WorldOutlookChart() { + return ( + + ) +} diff --git a/apps/www/data/surveys/state-of-startups-2025.tsx b/apps/www/data/surveys/state-of-startups-2025.tsx index 161b729dd8b..b87044ea201 100644 --- a/apps/www/data/surveys/state-of-startups-2025.tsx +++ b/apps/www/data/surveys/state-of-startups-2025.tsx @@ -1,18 +1,447 @@ -export default (isMobile?: boolean) => ({ +const stateOfStartupsData = (isMobile?: boolean) => ({ metaTitle: 'State of Startups 2025', metaDescription: - 'Take the survey and learn the latest trends among builders in tech stacks, AI usage, problem domains, and more.', + 'The latest trends among builders in tech stacks, AI usage, problem domains, and more.', metaImage: '/images/state-of-startups/2025/state-of-startups-og.png', docsUrl: '', heroSection: { title: 'State of Startups 2025', - subheader: ( - <> - There's never been a better time to build. -
- Take our State of Startups survey and tell us how you're building. - - ), + subheader: + 'We surveyed over 1,800 startup founders and builders to uncover What’s powering modern startups: their stacks, their go-to-market motion, and their approach to AI.', + cta: 'This report is built for builders.', className: '[&_h1]:max-w-2xl', }, + pageChapters: [ + { + title: 'Who’s Building Startups', + shortTitle: 'Founder and Company', + description: + "Today's startup ecosystem is dominated by young, technical builders shipping fast with lean teams. They've done this before.", + pullQuote: { + quote: + "We’re a two-person team, both technical. It's not our first rodeo, and that experience helped us move way faster this time.", + author: 'John Doe', + authorPosition: 'Founder', + authorAvatar: '/images/twitter-profiles/qhvO9V6x_400x400.jpg', + }, + sections: [ + { + title: 'Roles and Experience', + description: + 'Most builders are young, technical founders. Many are on their second or third company.', + stats: [ + { number: 83, unit: '%', label: 'Founders that are technical' }, + { number: 82, unit: '%', label: 'Founders under age 40' }, + { number: 33, unit: '%', label: 'Founders that are repeat founders' }, + ], + charts: ['RoleChart'], + wordCloud: undefined, + summarizedAnswer: undefined, + rankedAnswersPair: undefined, + }, + { + title: 'Team Size and Funding', + description: + 'Startups are mostly bootstrapped or at early stages of funding. They are small teams, and usually less than a year old.', + stats: [ + { number: 91, unit: '%', label: 'Startups with less than 10 employees' }, + { number: 65, unit: '%', label: 'Startups under one year old' }, + { number: 6, unit: '%', label: 'Startups over 5 years old' }, + ], + charts: ['FundingStageChart'], + wordCloud: undefined, + summarizedAnswer: undefined, + rankedAnswersPair: undefined, + }, + { + title: 'Where They’re Based', + description: + 'Startups are building globally, but North America—especially San Francisco—remains overrepresented. Europe and Asia also feature prominently, with hubs like Toronto and NYC following close behind.', + stats: [ + { number: 17, unit: '%', label: 'Global startups based in Europe' }, + { number: 19, unit: '%', label: 'North American startups based in San Francisco' }, + { number: 9, unit: '%', label: 'North American startups based in New York City' }, + ], + charts: ['HeadquartersChart'], + wordCloud: undefined, + summarizedAnswer: undefined, + rankedAnswersPair: undefined, + }, + ], + }, + { + title: 'What Startups are Building', + shortTitle: 'Product and Market', + description: + 'Startups are still experimenting. They’re building a diverse mix of software products, iterating quickly, and pursuing monetization selectively.', + pullQuote: { + quote: + 'We’re experimenting in edtech for underserved regions. It’s early, but we’re learning fast.', + author: 'Sarah Johnson', + authorPosition: 'Founder', + authorAvatar: '/images/twitter-profiles/qhvO9V6x_400x400.jpg', + }, + sections: [ + { + title: 'Industries and Focus', + description: + 'Under-30s gravitate toward AI-driven productivity, education, and social tools; areas where rapid iteration and novelty matter. Over-50s skew toward SaaS and consumer products, often bringing domain-specific experience into more established markets. Developer tools and infrastructure attract all age groups.', + stats: [ + { number: 38, unit: '%', label: 'Founders under 30 building in AI/ML' }, + { number: 33, unit: '%', label: 'Startups building for developers' }, + { number: 24, unit: '%', label: 'Startups targeting consumers' }, + ], + charts: ['IndustryChart'], + + wordCloud: undefined, + summarizedAnswer: { + label: 'Problems startups are solving', + answers: [ + 'AI-powered productivity tools', + 'Agent workflows (internal or customer-facing)', + 'Career preparation and job search', + 'AI copilots for small businesses', + 'AI-enhanced education and tutoring', + 'Tools for solopreneurs and creators', + 'Developer experience and API abstraction', + 'Sales and outreach automation', + 'Healthcare access and diagnostics', + 'Financial planning and forecasting', + 'Sustainability and climate data', + 'Privacy and compliance automation', + 'Time management and prioritization', + 'Collaboration and communication', + 'Mental health and wellness tracking', + ], + }, + rankedAnswersPair: undefined, + }, + { + title: 'Traction and Early Growth', + description: + 'One in five startups joined an accelerator. Y Combinator is the most common choice, especially in North America. Elsewhere, participation was more evenly distributed. Pivoting remains the norm, and less than half of startups are monetizing today.', + stats: [ + { number: 59, unit: '%', label: 'Startups that have pivoted at least once' }, + { number: 58, unit: '%', label: 'Startups that are pre-revenue' }, + { number: 18, unit: '%', label: 'Startups that have joined accelerators' }, + ], + charts: ['AcceleratorParticipationChart'], + wordCloud: undefined, + summarizedAnswer: undefined, + rankedAnswersPair: undefined, + }, + ], + }, + { + title: 'What’s in a Startup’s Tech Stack', + shortTitle: 'Tech Stack', + description: + 'The modern stack centers around open tools, modular infrastructure, and cautious spending.', + pullQuote: { + quote: + 'We’re experimenting in edtech for underserved regions. It’s early, but we’re learning fast.', + author: 'Sarah Johnson', + authorPosition: 'Founder', + authorAvatar: '/images/twitter-profiles/qhvO9V6x_400x400.jpg', + }, + sections: [ + { + title: 'Frameworks and Cloud Infra', + description: + 'Supabase and Postgres dominate backend infra. React and Node top the frontend and backend respectively. Cursor, Claude, and VS Code lead AI-assisted development. Developer tools like GitHub, Stripe, and Postman round out the stack.', + stats: [ + { + number: 75, + unit: '%', + label: 'Startups with a JavaScript framework in their frontend stack', + }, + { + number: 62, + unit: '%', + label: 'Startups with Supabase in their cloud provider stack', + }, + { number: 61, unit: '%', label: 'Startups with Node.js in their backend stack' }, + ], + charts: ['DatabasesChart'], + + wordCloud: undefined, + summarizedAnswer: undefined, + rankedAnswersPair: undefined, + }, + { + title: 'Dev Tools and Time Savers', + description: + 'AI coding tools are indispensable for startups, and not just Cursor and Visual Studio Code. ‘Vibe coding’ tools like Loveable, Bolt.new, and v0 are also common.', + stats: [ + { number: 57, unit: '%', label: 'Startups that pay for OpenAI or ChatGPT' }, + { number: 37, unit: '%', label: 'Startups that pay for Cursor' }, + { number: 5, unit: '%', label: 'Startups that don’t pay for AI tools at all' }, + ], + charts: ['AICodingToolsChart'], + wordCloud: { + label: 'Must-have developer tools by keyword frequency', + words: [ + { text: 'cursor', count: 495 }, + { text: 'code', count: 396 }, + { text: 'supabase', count: 302 }, + { text: 'github', count: 272 }, + { text: 'vscode', count: 160 }, + { text: 'claude', count: 143 }, + { text: 'docker', count: 114 }, + { text: 'git', count: 113 }, + { text: 'studio', count: 112 }, + { text: 'chatgpt', count: 92 }, + { text: 'postman', count: 89 }, + { text: 'copilot', count: 88 }, + { text: 'lovable', count: 83 }, + { text: 'visual', count: 76 }, + { text: 'vercel', count: 71 }, + { text: 'react', count: 70 }, + { text: 'figma', count: 68 }, + { text: 'ide', count: 58 }, + { text: 'windsurf', count: 51 }, + { text: 'backend', count: 44 }, + { text: 'api', count: 40 }, + { text: 'google', count: 37 }, + { text: 'testing', count: 36 }, + { text: 'tailwind', count: 35 }, + { text: 'chrome', count: 34 }, + { text: 'typescript', count: 33 }, + { text: 'control', count: 33 }, + { text: 'python', count: 32 }, + { text: 'gemini', count: 30 }, + ], + }, + summarizedAnswer: { + label: 'Tools that startups wish existed', + answers: [ + 'Unified backend platform combining auth, edge, database, and queues', + 'AI agents with real memory and workflow context', + 'Local-first dev environments that sync to Supabase or Git', + 'AI copilots for sales, marketing, or documentation', + 'UI builders with direct-to-code export and stateful logic', + 'Better CLI-driven or REPL-native dev tools', + 'Automated integration layers between SaaS APIs', + 'Real-time dashboards that don’t require BI tools', + 'Supabase + Neon or PlanetScale seamless sync', + 'One-click staging, testing, and preview environments', + 'AI validators for production database migrations', + 'Visual version control and state inspection for app logic', + 'Time-aware tools (versioned environments, snapshots, undoable infra)', + 'GPT-based toolchain composers (meta-dev agents)', + 'Agent-like task runners for cron jobs, workflows, monitoring', + ], + }, + rankedAnswersPair: undefined, + }, + ], + }, + { + title: 'How Startups are Integrating AI', + shortTitle: 'AI and Agents', + description: + 'AI is a core product capability, not an afterthought. Most teams are using models like OpenAI or Claude for real features, not just demos.', + pullQuote: { + quote: + 'AI is baked into the core. Semantic search and summarisation are what make the product usable.', + author: 'Tom Anderson', + authorPosition: 'Founder', + authorAvatar: '/images/twitter-profiles/qhvO9V6x_400x400.jpg', + }, + sections: [ + { + title: 'In-Product AI Use', + description: + 'AI is increasingly embedded in product workflows, not bolted on. Most startups are already integrating models like OpenAI or Claude, especially for semantic search, summarisation, and customer support. Half are building agents to automate real tasks, from onboarding flows to sales triage.', + stats: [ + { + number: 74, + unit: '%', + label: 'Startups using AI in their product', + }, + { number: 50, unit: '%', label: 'Startups building agents within their product' }, + { + number: 34, + unit: '%', + label: 'Startups adding natural language interfaces to their product', + }, + ], + charts: ['AIModelsChart'], + wordCloud: undefined, + summarizedAnswer: { + label: 'Most important AI use cases in product', + answers: [ + 'Summarization / content generation', + 'Recommendations / personalization', + 'Workflow / agent-based automation', + 'Search / semantic search', + 'Customer support automation', + ], + }, + rankedAnswersPair: undefined, + }, + ], + }, + { + title: 'Where Startups Go to Learn', + shortTitle: 'Influence', + description: 'Online communities are the learning engine behind every early-stage startup.', + pullQuote: { + quote: + 'I mostly lurk, but Twitter and Discord have been where I find the best tools and smartest minds.', + author: 'Emma Davis', + authorPosition: 'Founder', + authorAvatar: '/images/twitter-profiles/qhvO9V6x_400x400.jpg', + }, + sections: [ + { + title: 'Online Communities', + description: + 'There is a healthy diaspora of important online communities used by respondents. That said, many are just lurking; few actively contribute to the discussion.', + stats: [ + { number: 58, unit: '%', label: 'Engineers using LinkedIn regularly' }, + { number: 46, unit: '%', label: 'Founders using X (Twitter) regularly' }, + { number: 7, unit: '%', label: 'Respondents that don’t use social media at all' }, + ], + charts: ['RegularSocialMediaUseChart'], + wordCloud: undefined, + summarizedAnswer: undefined, + rankedAnswersPair: undefined, + }, + { + title: 'Inspiration Stack', + description: + 'Founders follow newsletters like TLDR and Lenny’s, listen to The Diary of a CEO and Founders Podcasts. Tool discovery happens quite often via YouTube or GitHub. Physical event participation remains low.', + stats: [ + { number: 45, unit: '%', label: 'Respondents that listen to industry podcasts' }, + { number: 36, unit: '%', label: 'Respondents that have built a developer community' }, + { + number: 20, + unit: '%', + label: 'Respondents that pay for at least one industry newsletter', + }, + ], + charts: ['NewIdeasChart'], + wordCloud: undefined, + summarizedAnswer: undefined, + rankedAnswersPair: [ + { + label: 'Top podcasts listened to', + answers: ['The Diary of a CEO', 'Founders Podcasts', 'Lightcone'], + }, + { + label: 'Top newsletters subscribed to', + answers: ['TLDR', 'Lenny’s Newsletter', 'The Pragmatic Engineer'], + }, + ], + }, + ], + }, + { + title: 'How Startups are Finding Customers', + shortTitle: 'Go-To-Market', + description: + 'Startups start selling through their networks and dev communities. Only when they grow do they layer in more structured growth via CRMs and sales.', + pullQuote: { + quote: + 'Our first 10 customers came from one tweet. No landing page, no funnel. Just good timing and network.', + author: 'Sophie Lee', + authorPosition: 'Founder', + authorAvatar: '/images/twitter-profiles/qhvO9V6x_400x400.jpg', + }, + sections: [ + { + title: 'Initial Customers', + description: + 'Founders earn their earliest customers through networks, communities, and inbound content. Not performance marketing. Paid acquisition rarely works early on.', + stats: [ + { + number: 57, + unit: '%', + label: 'Startups that find their initial customers through word of mouth', + }, + { number: 47, unit: '%', label: 'Startups that engage users through social media' }, + { number: 39, unit: '%', label: 'Startups that are still experimenting with pricing' }, + ], + charts: ['InitialPayingCustomersChart'], + wordCloud: undefined, + summarizedAnswer: undefined, + rankedAnswersPair: undefined, + }, + { + title: 'Founder-led Sales', + description: + 'Sales is still founder-led at most startups. Dedicated sales hires usually don’t arrive until after 10+ employees. Many still use Google Sheets or nothing at all to track sales activity.', + stats: [ + { + number: 75, + unit: '%', + label: 'Startups with their founders still directly responsible for sales', + }, + { + number: 18, + unit: '%', + label: 'Startups that got their initial customers from personal networks', + }, + { number: 13, unit: '%', label: 'Startups that have a dedicated sales team' }, + ], + charts: ['SalesToolsChart'], + wordCloud: undefined, + summarizedAnswer: undefined, + rankedAnswersPair: undefined, + }, + ], + }, + { + title: 'Biggest Challenges for Startups', + shortTitle: 'Outlook', + description: + 'Startups remain optimistic about the future but are weighed down by technical complexity, customer acquisition hurdles, and a wish list of tools that still don’t exist.', + sections: [ + { + title: 'The Road Ahead', + description: + 'The hardest problems are still the oldest ones: customer acquisition, product-market fit, and complexity. Startups cite AI-assisted coding and backend services as major time-savers, but many are still missing critical tools they want. Especially around onboarding, dashboards, and agents.', + stats: [ + { + number: 81, + unit: '%', + label: 'Respondents that evaluate potential tools via hands-on experience', + }, + { + number: 29, + unit: '%', + label: 'Respondents that saved the most time via AI coding assistance', + }, + { + number: 14, + unit: '%', + label: 'Respondents that saved the most time via BaaS platforms like Supabase', + }, + ], + charts: ['BiggestChallengeChart'], + + wordCloud: undefined, + summarizedAnswer: undefined, + rankedAnswersPair: undefined, + }, + { + title: 'Worldview and Optimism', + description: + "Most startup founders remain upbeat about the future, but that confidence isn't shared equally. Engineers and marketers show more caution.", + stats: [ + { number: 61, unit: '%', label: 'Founders that are optimistic' }, + { number: 32, unit: '%', label: 'Operations leads that are optimistic' }, + { number: 42, unit: '%', label: 'Other roles that are optimistic' }, + ], + charts: ['WorldOutlookChart'], + wordCloud: undefined, + summarizedAnswer: undefined, + rankedAnswersPair: undefined, + }, + ], + }, + ], }) + +export default stateOfStartupsData diff --git a/apps/www/pages/state-of-startups.tsx b/apps/www/pages/state-of-startups.tsx index 7a57200e589..05c9341ced3 100644 --- a/apps/www/pages/state-of-startups.tsx +++ b/apps/www/pages/state-of-startups.tsx @@ -1,11 +1,12 @@ import { useEffect, useRef, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' + import { animate, createSpring, createTimeline, stagger } from 'animejs' import Link from 'next/link' -import Image from 'next/image' import { NextSeo } from 'next-seo' +import { Maximize2, X } from 'lucide-react' -import { Button, Checkbox, cn } from 'ui' -import { PopupFrame } from 'ui-patterns' +import { Button, Checkbox, cn, Card, CardHeader, CardTitle, CardContent } from 'ui' import { Input } from 'ui/src/components/shadcn/ui/input' import { Label } from 'ui/src/components/shadcn/ui/label' import DefaultLayout from '~/components/Layouts/Default' @@ -14,352 +15,208 @@ import { useSendTelemetryEvent } from '~/lib/telemetry' import data from '~/data/surveys/state-of-startups-2025' -interface FormData { - email: string - terms: boolean -} - -interface FormItem { - type: 'email' | 'checkbox' - label: string - placeholder: string - required: boolean - className?: string - component: typeof Input | typeof Checkbox -} - -type FormConfig = { - [K in keyof FormData]: FormItem -} - -const formConfig: FormConfig = { - email: { - type: 'email', - label: 'Email', - placeholder: 'Email', - required: true, - className: '', - component: Input, - }, - terms: { - type: 'checkbox', - label: '', - placeholder: '', - required: true, - className: '', - component: Checkbox, - }, -} - -const defaultFormValue: FormData = { - email: '', - terms: false, -} - -const isValidEmail = (email: string): boolean => { - const emailPattern = /^[\w-\.+]+@([\w-]+\.)+[\w-]{2,8}$/ - return emailPattern.test(email) -} +import { SurveyChapter } from '~/components/SurveyResults/SurveyChapter' +import { SurveyChapterSection } from '~/components/SurveyResults/SurveyChapterSection' function StateOfStartupsPage() { const pageData = data() - const meta_title = pageData.metaTitle - const meta_description = pageData.metaDescription - const meta_image = pageData.metaImage + const [showFloatingToc, setShowFloatingToc] = useState(false) + const [isTocOpen, setIsTocOpen] = useState(false) + const [activeChapter, setActiveChapter] = useState(1) + const tocRef = useRef(null) + const heroRef = useRef(null) - const [formData, setFormData] = useState(defaultFormValue) - const [honeypot, setHoneypot] = useState('') // field to prevent spam - const [errors, setErrors] = useState<{ [key: string]: string }>({}) - const [isSubmitting, setIsSubmitting] = useState(false) - const [success, setSuccess] = useState(null) - const [startTime, setStartTime] = useState(0) + // Scroll detection to show floating ToC + useEffect(() => { + const handleScroll = () => { + const scrollY = window.scrollY + const heroElement = heroRef.current - const sendTelemetryEvent = useSendTelemetryEvent() + if (heroElement) { + const heroRect = heroElement.getBoundingClientRect() - const handleChange = (e: React.ChangeEvent) => { - const { name, value } = e.target - setErrors({}) - setFormData((prev) => ({ ...prev, [name]: value })) - } - - const handleReset = (e: React.MouseEvent) => { - e.preventDefault() - setFormData(defaultFormValue) - setSuccess(null) - setErrors({}) - } - - const validate = (): boolean => { - const newErrors: { [key in keyof FormData]?: string } = {} - - // Check required fields - for (const key in formConfig) { - if (formConfig[key as keyof FormData].required && !formData[key as keyof FormData]) { - if (key === 'email') { - newErrors[key as keyof FormData] = `Email is required` - } else if (key === 'terms') { - newErrors[key as keyof FormData] = `You must agree to the terms` + // Show floating ToC when the hero section is completely out of view + if (heroRect.bottom < 0) { + setShowFloatingToc(true) + } else { + setShowFloatingToc(false) + setIsTocOpen(false) } } } - // Validate email - if (formData.email && !isValidEmail(formData.email)) { - newErrors.email = 'Invalid email address' - } + window.addEventListener('scroll', handleScroll) + return () => window.removeEventListener('scroll', handleScroll) + }, []) - setErrors(newErrors) + // Active chapter detection + useEffect(() => { + const handleScroll = () => { + const chapters = pageData.pageChapters + const scrollY = window.scrollY + 100 // Offset for better detection - // Return validation status, also check if honeypot is filled (indicating a bot) - return Object.keys(newErrors).length === 0 && honeypot === '' - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - const currentTime = Date.now() - const timeElapsed = (currentTime - startTime) / 1000 - - // Spam prevention: Reject form if submitted too quickly (less than 3 seconds) - if (timeElapsed < 3) { - setErrors({ general: 'Submission too fast. Please fill the form correctly.' }) - return - } - - if (!validate()) { - return - } - - setIsSubmitting(true) - setSuccess(null) - - try { - const response = await fetch('/api-v2/submit-form-sos2025-newsletter', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(formData), - }) - - if (response.ok) { - setSuccess('Thank you for your submission!') - setFormData({ email: '', terms: false }) - } else { - const errorData = await response.json() - setErrors({ general: `Submission failed: ${errorData.message}` }) + 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 + } } - } catch (error) { - setErrors({ general: 'An unexpected error occurred. Please try again.' }) - } finally { - setIsSubmitting(false) } + + window.addEventListener('scroll', handleScroll) + return () => window.removeEventListener('scroll', handleScroll) + }, [pageData.pageChapters]) + + // 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]) + + // Floating Table of Contents component + const FloatingTableOfContents = () => { + const currentChapter = pageData.pageChapters[activeChapter - 1] + + if (!showFloatingToc) return null + + return ( +
+
+ {/* Closed state - shows current chapter */} + + + {/* Open state - shows full table of contents */} + {isTocOpen && ( + +
    + {pageData.pageChapters.map((chapter, chapterIndex) => ( + + setIsTocOpen(false)} + className={cn( + 'block py-2 rounded-lg text-sm transition-colors text-balance text-center', + chapterIndex + 1 === activeChapter + ? 'bg-brand/10 text-brand-link dark:text-brand' + : 'text-foreground-light hover:text-foreground hover:bg-surface-300' + )} + > + {chapter.title} + + + ))} +
+
+ )} +
+
+ ) } - useEffect(() => { - setStartTime(Date.now()) - }, []) + // Inline Table of Contents component (expanded only) + const InlineTableOfContents = () => { + return ( +
+
    + {pageData.pageChapters.map((chapter, chapterIndex) => ( +
  1. + + {chapter.title} + +
  2. + ))} +
+
+ ) + } return ( <> - - - - -
-

Stay in touch

-

- Sign up for our newsletter to be notified when the survey results are available. -

-
- {success ? ( -
-

{success}

- -
- ) : ( -
-
- {/* Spam prevention */} - ) => - setHoneypot(e.target.value) - } - style={{ display: 'none' }} - aria-hidden="true" - /> - - -
-
- - -
-
- {errors['email'] ? errors['email'] : errors['terms']} -
-
- )} -
-
-
-
- - ) -} + {/* */} + + {/* Floating version */} + -const Hero = (props: any) => { - const animRef = useRef(null) - - useEffect(() => { - if (!animRef.current) return - - const strings = [ - "What's your tech stack?", - "What's your favorite AI developer tool?", - 'Which vector database are you using?', - 'Are you building AI Agents?', - 'Do you use OpenTelemetry?', - 'Where do you go to learn?', - ] - - let currentIndex = 0 - - const animateText = () => { - const animatedText = animRef.current?.querySelector('#anim') - if (!animatedText) return - - const currentText = strings[currentIndex] - - animatedText.textContent = currentText - // Split by words and wrap each word, then wrap letters within each word - animatedText.innerHTML = currentText - .split(' ') - .map((word) => { - if (word.trim() === '') return ' ' - const wrappedLetters = word.replace( - /\S/g, - "$&" - ) - return `${wrappedLetters}` - }) - .join(' ') - - createTimeline({ - onComplete: () => { - currentIndex = (currentIndex + 1) % strings.length - setTimeout(() => { - animateOut() - }, 100) - }, - }).add(animatedText.querySelectorAll('.letter'), { - opacity: [0, 1], - translateY: [-8, 0], - ease: createSpring({ stiffness: 150, damping: 15 }), - duration: 500, - delay: stagger(10), - }) - } - - const animateOut = () => { - const animatedText = animRef.current?.querySelector('#anim') - if (!animatedText) return - - animate(animatedText.querySelectorAll('.letter'), { - opacity: [1, 0], - translateY: [0, 8], - ease: 'inExpo', - duration: 500, - delay: stagger(10), - onComplete: () => { - setTimeout(animateText, -100) - }, - }) - } - - animateText() - - return () => {} - }, []) - - return ( - <> -
-
-
+ {/* Previously */} +
+ {/* SVG shapes container */} +
{ cy="0" r="1" gradientUnits="userSpaceOnUse" - gradientTransform="translate(349.764 144.755) rotate(-132.179) scale(202.74 202.839)" + gradientTransform="translate(349.764 247.245) rotate(47.821) scale(202.74 202.839)" > - - + + -
+ {/*
*/} +
{ cy="0" r="1" gradientUnits="userSpaceOnUse" - gradientTransform="translate(571.212 579.87) rotate(122.182) scale(542.117 690.275)" + gradientTransform="translate(571.212 539.13) rotate(-57.818) scale(542.117 690.275)" > - - - - + {/* Inner core */} + + {/* Inner band */} + + {/* Outer band */} + + {/* Outermost band */} + { cy="0" r="1" gradientUnits="userSpaceOnUse" - gradientTransform="translate(814.301 175.03) rotate(-38.9601) scale(142.974 294.371)" + gradientTransform="translate(814.301 944.97) rotate(141.0399) scale(142.974 294.371)" > - - + {/* Outer slither ring */} + + @@ -443,90 +301,59 @@ const Hero = (props: any) => {
- + {/* Text container */} +
+

+ + State of +
Startups +
+ 2025 +

+ +
+

{pageData.heroSection.subheader}

+ + {/* Inline version - always expanded */} +
+ +
+ +

{pageData.heroSection.cta}

+
+ +
+ + {pageData.pageChapters.map((chapter, chapterIndex) => ( + <> + - - - - - - - - -
-
-
-
- Supabase icon ( + -
-
-
- {props.icon || props.title ? ( -
- {props.title && ( -

- {props.title} -

- )} -
- ) : null} -
-
-
-
- State of Startups -
-
-

{props.subheader}

-
-
- - Take the survey - - } - className="[&_.modal-content]:min-h-[650px] [&_.modal-content]:!h-[75vh] [&_.modal-content]:flex [&_.modal-content]:flex-col" - > -
-