mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 21:59:16 +03:00
wip: fist commit
This commit is contained in:
1 parent
112cc89c3d
commit
b364330e51
47 files changed
+3609
-433
No files matched your search
@@ -0,0 +1,39 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateAICodingToolsSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Which AI coding tools do you use?"
|
||||
targetColumn="ai_coding_tools"
|
||||
filterColumns={['person_age', 'team_count', 'funding_stage']}
|
||||
generateSQLQuery={generateAICodingToolsSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateDatabasesSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Which AI models are you using or planning to use?"
|
||||
targetColumn="ai_models_used"
|
||||
filterColumns={['funding_stage', 'startup_age', 'currently_monetizing']}
|
||||
generateSQLQuery={generateDatabasesSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateAcceleratorParticipationSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="If your startup has participated in an accelerator, which one?"
|
||||
targetColumn="accelerator_participation_normalized"
|
||||
filterColumns={['previous_company', 'headquarters', 'funding_stage']}
|
||||
generateSQLQuery={generateAcceleratorParticipationSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<GenericChartWithQuery
|
||||
title="What is your age?"
|
||||
targetColumn="person_age"
|
||||
filterColumns={['role', 'headquarters']}
|
||||
generateSQLQuery={generatePersonAgeSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateBackendStackSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="What is your startup's backend stack?"
|
||||
targetColumn="backend_stack"
|
||||
filterColumns={['headquarters', 'industry_normalized', 'currently_monetizing']}
|
||||
generateSQLQuery={generateBackendStackSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<GenericChartWithQuery
|
||||
title="What’s the biggest business challenge your startup is facing today?"
|
||||
targetColumn="biggest_challenge"
|
||||
filterColumns={['team_count', 'ai_models_used', 'currently_monetizing']}
|
||||
generateSQLQuery={generateBiggestChallengeSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateCloudProvidersSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Which cloud provider(s) is your startup using?"
|
||||
targetColumn="observability"
|
||||
filterColumns={['currently_monetizing', 'money_raised', 'backend_stack']}
|
||||
generateSQLQuery={generateCloudProvidersSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateDataWarehousesSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Which data warehouse(s) is your startup using? (Filtered)"
|
||||
targetColumn="data_warehouses"
|
||||
filterColumns={['headquarters', 'industry_normalized', 'person_age', 'frontend_stack']}
|
||||
generateSQLQuery={generateDataWarehousesSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateDatabasesSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Which database(s) is your startup using?"
|
||||
targetColumn="databases"
|
||||
filterColumns={['team_count', 'ai_models_used', 'backend_stack']}
|
||||
generateSQLQuery={generateDatabasesSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
iconRight={<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />}
|
||||
>
|
||||
{selectedValue === 'unset' ? (
|
||||
<div className="w-full flex gap-1">
|
||||
<p className="text-foreground-lighter">{filterConfig.label.toLowerCase()}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex gap-1">
|
||||
<p className="text-foreground-lighter">{filterConfig.label.toLowerCase()}</p>
|
||||
<p className="text-foreground">{selectedValue}</p>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{filterConfig.options
|
||||
.filter((opt) => opt.value !== 'unset')
|
||||
.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => setFilterValue(filterKey, option.value)}
|
||||
className={selectedValue === option.value ? 'text-brand-600' : ''}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
{selectedValue !== 'unset' && (
|
||||
<div className="border-t border-border mt-1 pt-1">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setFilterValue(filterKey, 'unset')}
|
||||
className="text-foreground-lighter"
|
||||
>
|
||||
Clear filter
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateFrontendStackSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="What frontend technologies are your startup using?"
|
||||
targetColumn="frontend_stack"
|
||||
filterColumns={['headquarters', 'industry_normalized', 'person_age']}
|
||||
generateSQLQuery={generateFrontendStackSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>How many full-time employees does your startup have?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{
|
||||
right: 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid horizontal={false} />
|
||||
<YAxis
|
||||
dataKey="label"
|
||||
type="category"
|
||||
tickLine={false}
|
||||
tickMargin={64}
|
||||
axisLine={false}
|
||||
// tickFormatter={(value) => 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
|
||||
}}
|
||||
/>
|
||||
<XAxis dataKey="value" type="number" hide />
|
||||
{/* <ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="line" />}
|
||||
/> */}
|
||||
<Bar dataKey="value" layout="vertical" radius={4}>
|
||||
{/* To set per-bar colors in Recharts, `fill` should be applied to the Cell child */}
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={index === 0 ? 'hsl(var(--brand-default))' : 'hsl(var(--brand-300))'}
|
||||
/>
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="value"
|
||||
position="right"
|
||||
offset={8}
|
||||
className="fill-foreground"
|
||||
fontSize={12}
|
||||
formatter={(value: number) => `${value}%`}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<GenericChartWithQuery
|
||||
title="What stage of funding is your startup in?"
|
||||
targetColumn="funding_stage"
|
||||
filterColumns={['headquarters', 'team_count', 'previous_company']}
|
||||
generateSQLQuery={generateFundingStageSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<Filters>({})
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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<ChartDataItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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, string>) => string
|
||||
}
|
||||
|
||||
export function GenericChartWithQuery({
|
||||
title,
|
||||
targetColumn,
|
||||
filterColumns,
|
||||
generateSQLQuery,
|
||||
}: GenericChartWithQueryProps) {
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
const chartRef = useRef<HTMLDivElement>(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<string, string>, 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<string, string>) => ({
|
||||
...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 (
|
||||
<div ref={chartRef} className="w-full bg-surface-100 border-y">
|
||||
<header className="px-6 py-5">
|
||||
<h3 className="text-foreground-light text-sm">{title}</h3>
|
||||
</header>
|
||||
<div
|
||||
className={`${view === 'chart' ? 'bg-surface-100' : 'bg-surface-75'} border-b last:border-none`}
|
||||
>
|
||||
<div
|
||||
className="overflow-hidden relative"
|
||||
style={{
|
||||
height: isExpanded ? 'auto' : `${FIXED_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
{error ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-danger">Error: {error}</p>
|
||||
</div>
|
||||
) : view === 'chart' ? (
|
||||
<div className="flex flex-col h-full w-full justify-between px-6 pt-4 pb-10">
|
||||
{/* Each bar as a vertical stack: label above, bar below */}
|
||||
<div
|
||||
className="flex flex-col gap-10"
|
||||
style={{ height: isExpanded ? 'auto' : `${CHART_HEIGHT}px` }}
|
||||
>
|
||||
{displayData.map((item, index) => (
|
||||
<div key={index} className="flex flex-col">
|
||||
{/* Text above the bar */}
|
||||
<div
|
||||
className={`mb-2 flex flex-row justify-between text-sm font-mono uppercase tracking-widest tabular-nums transition-colors duration-300 ${
|
||||
shouldAnimateBars
|
||||
? item.value === maxValue
|
||||
? 'text-brand-link dark:text-brand'
|
||||
: 'text-foreground'
|
||||
: 'text-foreground-muted'
|
||||
}`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span>{item.value < 1 ? '<1%' : `${item.value}%`}</span>
|
||||
</div>
|
||||
|
||||
{/* Entire bar (including background) */}
|
||||
<div
|
||||
className="h-[14px] flex items-center"
|
||||
style={
|
||||
{
|
||||
background: `repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 4px,
|
||||
color-mix(in srgb, hsl(var(--foreground-muted)) 30%, transparent) 4px,
|
||||
color-mix(in srgb, hsl(var(--foreground-muted)) 30%, transparent) 6px
|
||||
)`,
|
||||
'--reference': maxValue,
|
||||
'--bar-value': item.rawValue || item.value,
|
||||
'--index': index,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Filled portion of the bar */}
|
||||
<div
|
||||
className={`h-full ${item.value === maxValue ? 'bg-brand' : 'bg-foreground-muted'}`}
|
||||
style={{
|
||||
width: `calc(max(0.5%, (var(--bar-value) / var(--reference)) * 100%))`,
|
||||
transform: shouldAnimateBars ? 'scaleX(1)' : 'scaleX(0)',
|
||||
transformOrigin: 'left',
|
||||
transition: `transform 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.1}s`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<CodeBlock lang="sql" showLineNumbers={true} className="rounded-none border-none">
|
||||
{sqlQuery}
|
||||
</CodeBlock>
|
||||
)}
|
||||
|
||||
{/* Expand button overlay */}
|
||||
{!isExpanded && !isLoading && !error && chartData.length > 3 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-center py-4 bg-gradient-to-b from-transparent to-background">
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
// iconRight={<UnfoldVertical />}
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className="shadow-sm"
|
||||
>
|
||||
{/* Show {chartData.length - 3} more */}
|
||||
Show more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and toggle */}
|
||||
<div className="flex flex-row flex-wrap gap-4 p-4 justify-between">
|
||||
{filters && activeFilters && setFilterValue && (
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{Object.entries(filters).map(([filterKey, filterConfig]) => (
|
||||
<FilterDropdown
|
||||
key={filterKey}
|
||||
filterKey={filterKey}
|
||||
filterConfig={filterConfig}
|
||||
selectedValue={activeFilters[filterKey]}
|
||||
setFilterValue={setFilterValue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<TwoOptionToggle
|
||||
options={['SQL', 'chart']}
|
||||
activeOption={view}
|
||||
onClickOption={setView}
|
||||
borderOverride="border-overlay"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateHeadquartersSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Where is your startup headquartered?"
|
||||
targetColumn="headquarters"
|
||||
filterColumns={['funding_stage', 'startup_age', 'previous_company']}
|
||||
generateSQLQuery={generateHeadquartersSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<GenericChartWithQuery
|
||||
title="What is your startup's primary industry or target customer segment?"
|
||||
targetColumn="industry_normalized"
|
||||
filterColumns={['person_age', 'funding_stage', 'market_model']}
|
||||
generateSQLQuery={generateIndustrySQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateInitialPayingCustomersSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Where did your startup’s initial paying customers come from?"
|
||||
targetColumn="initial_paying_customers"
|
||||
filterColumns={['industry_normalized', 'market_model', 'currently_monetizing']}
|
||||
generateSQLQuery={generateInitialPayingCustomersSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateMonetizationSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Is your startup monetizing today?"
|
||||
targetColumn="currently_monetizing"
|
||||
filterColumns={['headquarters', 'money_raised']}
|
||||
generateSQLQuery={generateMonetizationSQL}
|
||||
chartType="pie"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateMustHaveDevToolsSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="What are your must-have dev tools?"
|
||||
targetColumn="must_have_dev_tools"
|
||||
filterColumns={['currently_monetizing', 'money_raised', 'backend_stack']}
|
||||
generateSQLQuery={generateMustHaveDevToolsSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateNewIdeasSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Where do you usually discover new dev tools or startup ideas?"
|
||||
targetColumn="new_ideas"
|
||||
filterColumns={['person_age', 'role', 'industry_normalized']}
|
||||
generateSQLQuery={generateNewIdeasSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateNewslettersPaidForSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Which newsletters do you currently pay for subscriptions?"
|
||||
targetColumn="newsletters_paid_for"
|
||||
filterColumns={['headquarters', 'industry_normalized', 'currently_monetizing']}
|
||||
generateSQLQuery={generateNewslettersPaidForSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateObservabilitySQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Which tools are your startup currently using for monitoring and observability? (Filtered)"
|
||||
targetColumn="observability"
|
||||
filterColumns={['currently_monetizing', 'money_raised']}
|
||||
generateSQLQuery={generateObservabilitySQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateOpenTelemetrySQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Do you use OpenTelemetry in your observability stack?"
|
||||
targetColumn="use_opentelemetry"
|
||||
filterColumns={['currently_monetizing', 'money_raised']}
|
||||
generateSQLQuery={generateOpenTelemetrySQL}
|
||||
chartType="pie"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generatePivotFreqSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="How many times did your startup have to pivot before arriving at the current idea?"
|
||||
targetColumn="pivots_before_current"
|
||||
// filterColumns={['headquarters', 'currently_monetizing', 'person_age', 'previous_company']}
|
||||
filterColumns={['person_age', 'previous_company']}
|
||||
generateSQLQuery={generatePivotFreqSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generatePodcastsListenedToSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Which of these podcasts do you listen to regularly?"
|
||||
targetColumn="podcasts_listened_to"
|
||||
filterColumns={['headquarters', 'industry_normalized', 'currently_monetizing']}
|
||||
generateSQLQuery={generatePodcastsListenedToSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generatePreviousCompanySQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Had you started a company before your current startup?"
|
||||
targetColumn="previous_company"
|
||||
filterColumns={['person_age', 'money_raised']}
|
||||
generateSQLQuery={generatePreviousCompanySQL}
|
||||
chartType="pie"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generatePricingSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="TK: Pricing question"
|
||||
targetColumn="pricing"
|
||||
filterColumns={['team_count']}
|
||||
generateSQLQuery={generatePricingSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateProblemBeingSolvedSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="What problem is your startup solving?"
|
||||
targetColumn="problem_solving"
|
||||
filterColumns={['currently_monetizing', 'money_raised', 'backend_stack']}
|
||||
generateSQLQuery={generateProblemBeingSolvedSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="w-full overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-foreground-lighter">Loading data...</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-red-500">Error: {error}</div>
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig}>
|
||||
{chartType === 'bar' && (
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{
|
||||
right: 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid horizontal={false} />
|
||||
<YAxis
|
||||
dataKey="label"
|
||||
type="category"
|
||||
tickLine={false}
|
||||
tickMargin={64}
|
||||
axisLine={false}
|
||||
hide={false}
|
||||
width={64}
|
||||
tick={{
|
||||
className: 'text-foreground-lighter',
|
||||
fontSize: 12,
|
||||
textAnchor: 'start',
|
||||
dx: 0,
|
||||
}}
|
||||
/>
|
||||
<XAxis dataKey="value" type="number" hide />
|
||||
<Bar dataKey="value" layout="vertical" radius={4}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={index === 0 ? COLORS_BINARY[0] : COLORS_BINARY[1]}
|
||||
/>
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="value"
|
||||
position="right"
|
||||
offset={8}
|
||||
className="fill-foreground"
|
||||
fontSize={12}
|
||||
formatter={(value: number) => `${value}%`}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
)}
|
||||
{chartType === 'pie' && (
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
// startAngle={15}
|
||||
paddingAngle={2}
|
||||
// label={renderCustomizedLabel}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={
|
||||
chartData.length === 2
|
||||
? COLORS_BINARY[index % COLORS_BINARY.length]
|
||||
: COLORS[index % COLORS.length]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
{/* <SurveyCodeWindow
|
||||
code={sqlQuery}
|
||||
lang="sql"
|
||||
className="w-full"
|
||||
filters={filters}
|
||||
activeFilters={activeFilters}
|
||||
setFilterValue={setFilterValue}
|
||||
/> */}
|
||||
<div className="flex flex-col">
|
||||
{filters && activeFilters && setFilterValue && (
|
||||
<div className="flex flex-wrap gap-4 p-4 border-b border-border">
|
||||
{Object.entries(filters).map(([filterKey, filterConfig]) => (
|
||||
<FilterDropdown
|
||||
filterKey={filterKey}
|
||||
filterConfig={filterConfig}
|
||||
selectedValue={activeFilters[filterKey]}
|
||||
setFilterValue={setFilterValue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* <CodeBlock lang="sql" size="small" showLineNumbers={true} rounded={false}>
|
||||
{sqlQuery}
|
||||
</CodeBlock> */}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateRegularSocialMediaUseSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Which social media platforms do you use at least 3× per week?"
|
||||
targetColumn="regular_social_media_use"
|
||||
filterColumns={['role', 'industry_normalized', 'person_age']}
|
||||
generateSQLQuery={generateRegularSocialMediaUseSQL}
|
||||
// isPercentageData={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<GenericChartWithQuery
|
||||
title="What is your functional role at your startup?"
|
||||
targetColumn="person_role_normalized"
|
||||
filterColumns={['person_age', 'funding_stage', 'headquarters']}
|
||||
generateSQLQuery={generateRoleSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateSalesToolsSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="What tools are you using to manage your sales process?"
|
||||
targetColumn="sales_tools"
|
||||
filterColumns={['team_count', 'funding_stage', 'first_sales_hire_stage']}
|
||||
generateSQLQuery={generateSalesToolsSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateSubscriptionsSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Which of the following subscriptions does your startup pay for your team?"
|
||||
targetColumn="subscriptions"
|
||||
filterColumns={['headquarters', 'industry_normalized', 'currently_monetizing']}
|
||||
generateSQLQuery={generateSubscriptionsSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<section
|
||||
id={`chapter-${number}`}
|
||||
className="flex flex-col bg-200 border-t last-of-type:border-b pt-40"
|
||||
>
|
||||
<div className="flex flex-col container max-w-[60rem] mx-auto px-6 lg:px-0">
|
||||
<header className="mx-auto flex flex-col gap-4 text-center text-balance">
|
||||
<h2 className="text-3xl md:text-4xl xl:text-6xl text-balance leading-tight">{title}</h2>
|
||||
<p className="text-lg xl:text-xl text-foreground-light text-balance">{description}</p>
|
||||
</header>
|
||||
<div className="flex flex-col pb-32">{children}</div>
|
||||
</div>
|
||||
|
||||
{pullQuote && (
|
||||
<SurveyPullQuote
|
||||
quote={pullQuote.quote}
|
||||
author={pullQuote.author}
|
||||
authorPosition={pullQuote.authorPosition}
|
||||
authorAvatar={pullQuote.authorAvatar}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
id={title.toLowerCase().replace(/\s+/g, '-')}
|
||||
className="flex flex-col gap-12 border-t border-muted pt-24 mt-24"
|
||||
>
|
||||
<header className="flex flex-col gap-3 text-center text-balance max-w-[48rem] mx-auto pb-12">
|
||||
<h3 className="text-brand-link dark:text-brand font-mono uppercase tracking-widest text-sm">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-foreground text-xl leading-relaxed">{description}</p>
|
||||
</header>
|
||||
|
||||
{stats && (
|
||||
<aside className="flex flex-row flex-wrap divide-x divide-muted divide-opacity-50">
|
||||
{stats.map((stat, index) => (
|
||||
<SurveyStatCard
|
||||
key={index}
|
||||
progressValue={stat.number}
|
||||
unit={stat.unit}
|
||||
label={stat.label}
|
||||
/>
|
||||
))}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{charts?.map((chartName, index) => {
|
||||
const ChartComponent = chartComponents[chartName as keyof typeof chartComponents]
|
||||
return ChartComponent ? <ChartComponent key={index} /> : null
|
||||
})}
|
||||
|
||||
{rankedAnswersPair && <SurveyRankedAnswersPair rankedAnswersPair={rankedAnswersPair} />}
|
||||
|
||||
{wordCloud && <SurveyWordCloud label={wordCloud.label} answers={wordCloud.words} />}
|
||||
|
||||
{summarizedAnswer && (
|
||||
<SurveySummarizedAnswer label={summarizedAnswer.label} answers={summarizedAnswer.answers} />
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, any>
|
||||
activeFilters?: Record<string, string>
|
||||
setFilterValue?: (filterKey: string, value: string) => void
|
||||
}
|
||||
|
||||
export const SurveyCodeWindow = ({
|
||||
code,
|
||||
lang,
|
||||
style,
|
||||
className,
|
||||
showLineNumbers,
|
||||
filters,
|
||||
activeFilters,
|
||||
setFilterValue,
|
||||
}: Props) => {
|
||||
return (
|
||||
<CodeBlock lang={lang ?? 'js'} showLineNumbers={showLineNumbers}>
|
||||
{code}
|
||||
</CodeBlock>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<aside className="flex flex-col gap-4 text-center items-center px-6 py-24 bg-alternative border-t border-muted">
|
||||
{/* TODO add quote marks via CSS instead, hang quote marks outside*/}
|
||||
<p className="text-foreground-lighter text-2xl text-balance max-w-prose">“{quote}”</p>
|
||||
<Image
|
||||
src={authorAvatar || '/images/twitter-profiles/qhvO9V6x_400x400.jpg'}
|
||||
width={48}
|
||||
height={48}
|
||||
alt={`${author}'s avatar`}
|
||||
className="h-10 w-10 overflow-hidden rounded-full border border-control"
|
||||
/>
|
||||
<p>
|
||||
{author}
|
||||
<br />
|
||||
<span className="text-foreground-muted text-sm">{authorPosition}</span>
|
||||
</p>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export function SurveyRankedAnswersPair({
|
||||
rankedAnswersPair,
|
||||
}: {
|
||||
rankedAnswersPair: Array<{ label: string; answers: string[] }>
|
||||
}) {
|
||||
return (
|
||||
<aside className="flex flex-row flex-wrap divide-x divide-muted divide-opacity-50">
|
||||
{/* <div className="flex flex-row bg-surface-100 border border-default rounded-md divide-x"> */}
|
||||
{rankedAnswersPair.map((item, index) => (
|
||||
<div key={index} className="flex flex-col gap-3 flex-1 px-6 py-8">
|
||||
<ol className="flex flex-col gap-3">
|
||||
{item.answers.map((answer, answerIndex) => (
|
||||
<li key={answerIndex} className="font-mono text-brand">
|
||||
#{answerIndex + 1}. {answer}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="text-foreground-light text-sm">{item.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div ref={cardRef} className="flex-1 px-6 py-8 flex flex-col gap-4">
|
||||
{/* Progress bar */}
|
||||
{progressValue !== undefined && (
|
||||
<div
|
||||
className="h-[6px] flex items-center md:mr-12"
|
||||
style={
|
||||
{
|
||||
background: `repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 3px,
|
||||
color-mix(in srgb, hsl(var(--foreground-muted)) 50%, transparent) 3px,
|
||||
color-mix(in srgb, hsl(var(--foreground-muted)) 50%, transparent) 4px
|
||||
)`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-brand origin-left"
|
||||
style={{
|
||||
width: `calc(max(0.5%, (${progressValue} / ${maxValue}) * 100%))`,
|
||||
transform: shouldAnimateBar ? 'scaleX(1)' : 'scaleX(0)',
|
||||
transition: 'transform 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p
|
||||
className={`md:-ml-1 md:mt-8 text-2xl md:text-6xl font-mono tracking-tight inline-block flex flex-row items-baseline ${hasAnimated ? 'text-foreground' : 'text-foreground-muted'} transition-colors duration-1000`}
|
||||
>
|
||||
{formattedValue}
|
||||
<span className="md:text-4xl">{unit}</span>
|
||||
</p>
|
||||
<p className="text-foreground-light text-sm text-balance md:mr-6">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-3 px-6 py-8">
|
||||
<p className="font-mono text-brand text-2xl">{answers[0]}</p>
|
||||
<p className="text-foreground-light text-sm">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-6 py-8">
|
||||
<div className="min-h-[1.5rem] flex items-center">
|
||||
<p className="font-mono text-brand text-2xl transition-opacity duration-500">
|
||||
{answers[currentIndex]}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-foreground-light text-sm">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string[]>([])
|
||||
|
||||
// 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 (
|
||||
<aside className="flex flex-col gap-4 px-3 md:px-6">
|
||||
<ol className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4">
|
||||
{currentItems.map(({ text, count }, index) => (
|
||||
<li
|
||||
key={`${text}-${index}`}
|
||||
className={`py-4 border-t border-muted border-opacity-50 [&:nth-child(-n+2)]:border-t-0 sm:[&:nth-child(-n+3)]:border-t-0 md:[&:nth-child(-n+4)]:border-t-0`}
|
||||
>
|
||||
<span className="font-mono md:text-lg text-center text-foreground">
|
||||
{scramblingTexts[index].toUpperCase() || text.toUpperCase()}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<p className="text-foreground-lighter text-sm">{label}</p>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<GenericChartWithQuery
|
||||
title="How many full-time employees does your startup have?"
|
||||
targetColumn="team_count"
|
||||
filterColumns={['headquarters', 'funding_stage', 'person_age']}
|
||||
generateSQLQuery={generateTeamCountSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateTechnicalFoundersSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Are the founder(s) at your current startup technical?"
|
||||
targetColumn="founders_are_technical"
|
||||
filterColumns={['person_age', 'money_raised']}
|
||||
generateSQLQuery={generateTechnicalFoundersSQL}
|
||||
chartType="pie"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { GenericChartWithQuery } from './GenericChartWithQuery'
|
||||
|
||||
function generateVectorDatabasesSQL(activeFilters: Record<string, string>) {
|
||||
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 (
|
||||
<GenericChartWithQuery
|
||||
title="Which vector database(s) is your startup using? (Filtered)"
|
||||
targetColumn="vector_databases"
|
||||
filterColumns={['databases']}
|
||||
generateSQLQuery={generateVectorDatabasesSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<GenericChartWithQuery
|
||||
title="Given the state of the world, are you…"
|
||||
targetColumn="world_outlook"
|
||||
filterColumns={['role', 'funding_stage', 'headquarters']}
|
||||
generateSQLQuery={generateWorldOutlookSQL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
<br />
|
||||
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
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
const heroRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [formData, setFormData] = useState<FormData>(defaultFormValue)
|
||||
const [honeypot, setHoneypot] = useState<string>('') // field to prevent spam
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [startTime, setStartTime] = useState<number>(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<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target
|
||||
setErrors({})
|
||||
setFormData((prev) => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const handleReset = (e: React.MouseEvent<HTMLButtonElement, 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 (
|
||||
<div
|
||||
ref={tocRef}
|
||||
className="fixed top-20 left-1/2 transform -translate-x-1/2 z-50 transition-all duration-300"
|
||||
>
|
||||
<div className="relative">
|
||||
{/* Closed state - shows current chapter */}
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
onClick={() => setIsTocOpen(true)}
|
||||
className={cn(
|
||||
'flex flex-row gap-2 shadow-xl rounded-full px-2 pr-4',
|
||||
isTocOpen && 'hidden'
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex items-center gap-2 font-mono uppercase text-xs')}>
|
||||
{/* <motion.span
|
||||
className="bg-surface-100 border border-surface-200 rounded-xl w-5 h-5 flex items-center justify-center text-foreground-light"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
{activeChapter}
|
||||
</motion.span> */}
|
||||
<span className="bg-surface-100 border border-surface-200 rounded-xl w-5 h-5 flex items-center justify-center text-foreground-light">
|
||||
{activeChapter}
|
||||
</span>
|
||||
<motion.span
|
||||
key={currentChapter?.shortTitle}
|
||||
className="text-foreground tracking-widest"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
{currentChapter?.shortTitle}
|
||||
</motion.span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Open state - shows full table of contents */}
|
||||
{isTocOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.86, y: -10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ duration: 0.1, ease: 'easeOut' }}
|
||||
className="origin-[50%_25%] bg-background/75 backdrop-blur-lg border border-default rounded-xl shadow-xl overflow-hidden min-w-[280px]"
|
||||
>
|
||||
<ol className="max-h-[60vh] overflow-y-auto p-1 flex flex-col gap-1">
|
||||
{pageData.pageChapters.map((chapter, chapterIndex) => (
|
||||
<motion.li
|
||||
key={chapterIndex + 1}
|
||||
initial={{ opacity: 0, y: -2 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.1,
|
||||
ease: 'easeOut',
|
||||
delay:
|
||||
0.1 +
|
||||
Math.exp(-(pageData.pageChapters.length - chapterIndex - 1) * 0.3) * 0.2,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`#chapter-${chapterIndex + 1}`}
|
||||
onClick={() => 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}
|
||||
</Link>
|
||||
</motion.li>
|
||||
))}
|
||||
</ol>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setStartTime(Date.now())
|
||||
}, [])
|
||||
// Inline Table of Contents component (expanded only)
|
||||
const InlineTableOfContents = () => {
|
||||
return (
|
||||
<div className="bg-background/75 backdrop-blur-lg border border-default rounded-xl shadow-xl overflow-hidden min-w-[280px]">
|
||||
<ol className="max-h-[60vh] overflow-y-auto p-1 flex flex-col gap-1">
|
||||
{pageData.pageChapters.map((chapter, chapterIndex) => (
|
||||
<li key={chapterIndex + 1}>
|
||||
<Link
|
||||
href={`#chapter-${chapterIndex + 1}`}
|
||||
className="block py-2 rounded-lg text-sm transition-colors text-balance text-center text-foreground-light hover:text-foreground hover:bg-surface-300"
|
||||
>
|
||||
{chapter.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title={meta_title}
|
||||
description={meta_description}
|
||||
openGraph={{
|
||||
title: meta_title,
|
||||
description: meta_description,
|
||||
url: `https://supabase.com/state-of-startups`,
|
||||
images: [
|
||||
{
|
||||
url: meta_image,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<DefaultLayout className="!bg-alternative overflow-hidden sm:!overflow-visible">
|
||||
<Hero {...pageData.heroSection} />
|
||||
<SectionContainer>
|
||||
<div className="flex flex-col text-center gap-4 py-8 items-center justify-center">
|
||||
<h2 className="heading-gradient text-2xl sm:text-3xl xl:text-4xl">Stay in touch</h2>
|
||||
<p className="mx-auto text-foreground-lighter w-full">
|
||||
Sign up for our newsletter to be notified when the survey results are available.
|
||||
</p>
|
||||
<div className="w-full mt-4 flex items-center justify-center text-center gap-4">
|
||||
{success ? (
|
||||
<div className="flex flex-col h-full w-full min-w-[300px] gap-4 items-center justify-center opacity-0 transition-opacity animate-fade-in scale-1">
|
||||
<p className="text-center text-sm">{success}</p>
|
||||
<Button onClick={handleReset}>Reset</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
noValidate
|
||||
id="state-of-startups-form"
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-md flex flex-col gap-4 items-center"
|
||||
>
|
||||
<div className="w-full flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
{/* Spam prevention */}
|
||||
<input
|
||||
type="text"
|
||||
name="honeypot"
|
||||
value={honeypot}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setHoneypot(e.target.value)
|
||||
}
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
required
|
||||
onChange={handleChange}
|
||||
value={formData['email']}
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
/>
|
||||
<Button
|
||||
htmlType="submit"
|
||||
disabled={isSubmitting}
|
||||
size="small"
|
||||
onClick={() =>
|
||||
sendTelemetryEvent({
|
||||
action: 'register_for_state_of_startups_newsletter_clicked',
|
||||
properties: { buttonLocation: 'State of Startups 2025 Newsletter Form' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-left">
|
||||
<Checkbox
|
||||
required
|
||||
name="terms"
|
||||
id="terms"
|
||||
onChange={handleChange}
|
||||
className="[&>input]:m-0"
|
||||
/>
|
||||
<Label htmlFor="terms" className="text-foreground-lighter leading-5">
|
||||
We process your information in accordance with our{' '}
|
||||
<Link href="/privacy" className="text-foreground-light hover:underline">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</Label>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-nowrap text-right gap-1 items-center text-xs leading-none transition-opacity opacity-0 text-foreground-lighter',
|
||||
errors['email'] && 'opacity-100 animate-fade-in',
|
||||
errors['terms'] && 'opacity-100 animate-fade-in'
|
||||
)}
|
||||
>
|
||||
{errors['email'] ? errors['email'] : errors['terms']}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionContainer>
|
||||
</DefaultLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{/* <NextSeo {...pageData.seo} /> */}
|
||||
<DefaultLayout className="!bg-alternative overflow-hidden">
|
||||
{/* Floating version */}
|
||||
<FloatingTableOfContents />
|
||||
|
||||
const Hero = (props: any) => {
|
||||
const animRef = useRef<HTMLDivElement | null>(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,
|
||||
"<span class='letter' style='opacity: 0; transform: translateY(-6px); display: inline-block;'>$&</span>"
|
||||
)
|
||||
return `<span class="word" style="display: inline-block; white-space: nowrap;">${wrappedLetters}</span>`
|
||||
})
|
||||
.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 (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'container relative w-full mx-auto px-6 py-8 md:py-16 sm:px-16 xl:px-20',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={animRef}
|
||||
className="flex flex-col text-center items-center justify-center min-h-[600px] lg:min-h-[70vh]"
|
||||
>
|
||||
<div className="absolute overflow-hidden -mx-[15vw] sm:mx-0 inset-0 w-[calc(100%+30vw)] sm:w-full h-full col-span-12 lg:col-span-7 xl:col-span-6 xl:col-start-7 flex justify-center">
|
||||
{/* Previously <Hero /> */}
|
||||
<section ref={heroRef} className="relative w-full">
|
||||
{/* SVG shapes container */}
|
||||
<div className="absolute inset-0 -top-[30rem] xs:w-[calc(100%+50vw)] xs:-mx-[25vw]">
|
||||
<svg
|
||||
width="558"
|
||||
height="392"
|
||||
viewBox="0 0 558 392"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="absolute w-full h-full inset-0 -top-40 lg:top-0 xl:-left-40 animate-pulse"
|
||||
className="absolute w-full h-full inset-0 -top-40 animate-pulse"
|
||||
style={{
|
||||
animationDuration: '20000ms',
|
||||
}}
|
||||
@@ -379,21 +236,23 @@ const Hero = (props: any) => {
|
||||
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)"
|
||||
>
|
||||
<stop stopColor="hsl(var(--foreground-default))" />
|
||||
<stop offset="1" stopColor="hsl(var(--foreground-default))" stopOpacity="0" />
|
||||
<stop stopColor="hsl(var(--brand-200))" />
|
||||
<stop offset="1" stopColor="hsl(var(--brand-200))" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div className="sm:w-full sm_h-full sm:flex sm:justify-center">
|
||||
{/* <div className="sm:w-full sm_h-full sm:flex sm:justify-center"></div> */}
|
||||
<div className="w-full h-full flex justify-center">
|
||||
<svg
|
||||
width="1119"
|
||||
height="1119"
|
||||
viewBox="0 0 1119 1119"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="sm:w-auto -mb-72 sm:-mt-60 md:-mt-40 lg:-mt-12 xl:mt-0 animate-spinner !ease-linear transform"
|
||||
// className="sm:w-auto -mb-72 sm:-mt-60 md:-mt-40 lg:-mt-12 xl:mt-0 animate-spinner !ease-linear transform"
|
||||
className="animate-spinner !ease-linear transform"
|
||||
style={{
|
||||
animationDuration: '20000ms',
|
||||
}}
|
||||
@@ -413,18 +272,16 @@ const Hero = (props: any) => {
|
||||
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)"
|
||||
>
|
||||
<stop stopColor="hsl(var(--border-muted))" />
|
||||
<stop
|
||||
offset="0.716346"
|
||||
stopColor="hsl(var(--background-alternative-default))"
|
||||
/>
|
||||
<stop
|
||||
offset="0.754808"
|
||||
stopColor="hsl(var(--background-alternative-default))"
|
||||
/>
|
||||
<stop offset="1" stopColor="hsl(var(--border-strong))" />
|
||||
{/* Inner core */}
|
||||
<stop stopColor="hsl(var(--brand-200))" />
|
||||
{/* Inner band */}
|
||||
<stop offset="0.675" stopColor="hsl(var(--brand-200))" />
|
||||
{/* Outer band */}
|
||||
<stop offset="0.75" stopColor="hsl(var(--brand-300))" />
|
||||
{/* Outermost band */}
|
||||
<stop offset="1" stopColor="hsl(var(--brand-500))" />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="paint2_radial_183_1690"
|
||||
@@ -432,10 +289,11 @@ const Hero = (props: any) => {
|
||||
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)"
|
||||
>
|
||||
<stop stopColor="hsl(var(--foreground-default))" />
|
||||
<stop offset="1" stopColor="hsl(var(--foreground-default))" stopOpacity="0" />
|
||||
{/* Outer slither ring */}
|
||||
<stop stopColor="hsl(var(--brand-600))" />
|
||||
<stop offset="1" stopColor="hsl(var(--brand-600))" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<clipPath id="clip0_183_1690">
|
||||
<rect width="1119" height="1119" fill="white" />
|
||||
@@ -443,90 +301,59 @@ const Hero = (props: any) => {
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<svg
|
||||
width="1096"
|
||||
height="482"
|
||||
viewBox="0 0 1096 482"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="absolute min-w-full inset-0 top-auto z-10"
|
||||
</div>
|
||||
{/* Text container */}
|
||||
<header className="container relative mt-[8rem] gap-[8rem] sm:gap-[14rem] w-full z-10 flex flex-col text-center justify-center items-center px-4 pb-16 mx-auto">
|
||||
<h1 className="flex flex-col gap-4 items-center">
|
||||
<span className="!leading-[90%] tracking-[-0.025em] text-8xl md:text-[14vw] lg:text-[12vw] xl:text-[10vw]">
|
||||
State of
|
||||
<br /> Startups
|
||||
</span>
|
||||
<span className="text-foreground text-2xl md:text-4xl leading-[100%]">2025</span>
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-col gap-4 max-w-prose">
|
||||
<p className="p md:text-2xl">{pageData.heroSection.subheader}</p>
|
||||
|
||||
{/* Inline version - always expanded */}
|
||||
<div className="relative flex justify-center mb-4">
|
||||
<InlineTableOfContents />
|
||||
</div>
|
||||
|
||||
<p className="p md:text-2xl">{pageData.heroSection.cta}</p>
|
||||
</div>
|
||||
</header>
|
||||
</section>
|
||||
|
||||
{pageData.pageChapters.map((chapter, chapterIndex) => (
|
||||
<>
|
||||
<SurveyChapter
|
||||
key={chapterIndex + 1}
|
||||
number={chapterIndex + 1}
|
||||
totalChapters={pageData.pageChapters.length}
|
||||
shortTitle={chapter.shortTitle}
|
||||
title={chapter.title}
|
||||
description={chapter.description}
|
||||
pullQuote={chapter.pullQuote}
|
||||
>
|
||||
<rect x="0.500488" width="1095" height="482" fill="url(#paint0_linear_183_1694)" />
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_183_1694"
|
||||
x1="922.165"
|
||||
y1="63.3564"
|
||||
x2="922.165"
|
||||
y2="419.772"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="hsl(var(--background-alternative-default))" stopOpacity="0" />
|
||||
<stop offset="1" stopColor="hsl(var(--background-alternative-default))" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="relative w-full z-10 flex flex-col items-center mx-auto">
|
||||
<div className="flex gap-2 mb-4 md:mb-8">
|
||||
<div className="w-11 h-11 relative flex items-center justify-center bg-default border rounded-lg">
|
||||
<Image
|
||||
src="/images/supabase-logo-icon.svg"
|
||||
alt="Supabase icon"
|
||||
width={60}
|
||||
height={60}
|
||||
className="w-6 h-6"
|
||||
{chapter.sections.map((section, sectionIndex) => (
|
||||
<SurveyChapterSection
|
||||
key={sectionIndex + 1}
|
||||
number={`${chapterIndex + 1}.${sectionIndex + 1}`}
|
||||
title={section.title}
|
||||
description={section.description}
|
||||
stats={section.stats}
|
||||
charts={section.charts}
|
||||
wordCloud={section.wordCloud}
|
||||
summarizedAnswer={section.summarizedAnswer}
|
||||
rankedAnswersPair={section.rankedAnswersPair}
|
||||
// pullQuote={section.pullQuote}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{props.icon || props.title ? (
|
||||
<div className="mb-2 flex justify-center items-center gap-3">
|
||||
{props.title && (
|
||||
<h1
|
||||
className="text-brand font-mono uppercase tracking-widest text-sm"
|
||||
key={`product-name-${props.title}`}
|
||||
>
|
||||
{props.title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn('flex flex-col gap-4 items-center')}>
|
||||
<div className="flex h-[150px] items-center">
|
||||
<div
|
||||
id="anim"
|
||||
className="will-change-transform leading-[120%] text-4xl sm:text-5xl md:text-6xl min-h-[4rem] max-w-2xl [&_.letter]:transform"
|
||||
>
|
||||
State of Startups
|
||||
</div>
|
||||
</div>
|
||||
<p className="p !text-foreground-light max-w-lg">{props.subheader}</p>
|
||||
</div>
|
||||
<div className="w-full sm:w-auto flex flex-col items-stretch sm:flex-row pt-2 sm:items-center gap-2">
|
||||
<PopupFrame
|
||||
trigger={
|
||||
<Button size="small" asChild>
|
||||
<span>Take the survey</span>
|
||||
</Button>
|
||||
}
|
||||
className="[&_.modal-content]:min-h-[650px] [&_.modal-content]:!h-[75vh] [&_.modal-content]:flex [&_.modal-content]:flex-col"
|
||||
>
|
||||
<div className="w-full !h-full flex-1 flex flex-col">
|
||||
<iframe
|
||||
src={`https://form.typeform.com/to/si6Z7XlW?embedded=true`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameBorder="0"
|
||||
className="w-full !min-h-full flex-1"
|
||||
/>
|
||||
</div>
|
||||
</PopupFrame>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</SurveyChapter>
|
||||
</>
|
||||
))}
|
||||
</DefaultLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user