diff --git a/clients/apps/web/public/assets/team/birk.png b/clients/apps/web/public/assets/team/birk.png new file mode 100644 index 0000000000..3319289149 Binary files /dev/null and b/clients/apps/web/public/assets/team/birk.png differ diff --git a/clients/apps/web/public/assets/team/emil.png b/clients/apps/web/public/assets/team/emil.png new file mode 100644 index 0000000000..90f565efa9 Binary files /dev/null and b/clients/apps/web/public/assets/team/emil.png differ diff --git a/clients/apps/web/public/assets/team/francois.png b/clients/apps/web/public/assets/team/francois.png new file mode 100644 index 0000000000..27f6053174 Binary files /dev/null and b/clients/apps/web/public/assets/team/francois.png differ diff --git a/clients/apps/web/src/app/pitch/layout.tsx b/clients/apps/web/src/app/pitch/layout.tsx new file mode 100644 index 0000000000..f1ad519303 --- /dev/null +++ b/clients/apps/web/src/app/pitch/layout.tsx @@ -0,0 +1,11 @@ +export default function PitchLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+ {children} +
+ ) +} diff --git a/clients/apps/web/src/app/pitch/page.tsx b/clients/apps/web/src/app/pitch/page.tsx new file mode 100644 index 0000000000..7addd630cf --- /dev/null +++ b/clients/apps/web/src/app/pitch/page.tsx @@ -0,0 +1,64 @@ +'use client' + +import { Footer } from '@/components/Pitch/Footer' +import { IndexSection } from '@/components/Pitch/sections/IndexSection' +import { InvestorsSection } from '@/components/Pitch/sections/InvestorsSection' +import { Polar20Section } from '@/components/Pitch/sections/Polar20' +import { TeamSection } from '@/components/Pitch/sections/TeamSection' +import { UsageBasedSection } from '@/components/Pitch/sections/UsageBasedSection' +import { WhySection } from '@/components/Pitch/sections/WhySection' +import { useArrowFocus } from '@/components/Pitch/useArrowFocus' +import { AnimatePresence, motion } from 'framer-motion' +import { useCallback, useState } from 'react' +import { PitchNavigation, sections } from '../../components/Pitch/Navigation' + +export default function PitchPage() { + const [index, setIndex] = useState(0) + + useArrowFocus({ + onLeft: () => setIndex((index) => Math.max(0, index - 1)), + onRight: () => + setIndex((index) => Math.min(sections.length - 1, index + 1)), + onNumberPress: (number) => + setIndex(Math.max(0, Math.min(number, sections.length - 1))), + }) + + const getActiveSection = useCallback(() => { + switch (index) { + case 0: + default: + return + case 1: + return + case 2: + return + case 3: + return + case 4: + return + case 5: + return + case 6: + return + } + }, [index]) + + return ( +
+
+ + + + {getActiveSection()} + + +
+
+
+ ) +} diff --git a/clients/apps/web/src/components/Pitch/Button.tsx b/clients/apps/web/src/components/Pitch/Button.tsx new file mode 100644 index 0000000000..b658c8987f --- /dev/null +++ b/clients/apps/web/src/components/Pitch/Button.tsx @@ -0,0 +1,28 @@ +import { PropsWithChildren } from 'react' +import { twMerge } from 'tailwind-merge' + +export interface ButtonProps { + className?: string + variant?: 'primary' | 'icon' +} + +export const Button = ({ + children, + className, + variant = 'primary', +}: PropsWithChildren) => { + const primaryClassName = 'p-2 text-xs' + const iconClassName = 'h-4 w-4 text-xxs' + + return ( + + ) +} diff --git a/clients/apps/web/src/components/Pitch/Chart.tsx b/clients/apps/web/src/components/Pitch/Chart.tsx new file mode 100644 index 0000000000..f1636ba76c --- /dev/null +++ b/clients/apps/web/src/components/Pitch/Chart.tsx @@ -0,0 +1,225 @@ +import { getValueFormatter } from '@/utils/metrics' +import * as Plot from '@observablehq/plot' +import { Interval, Metric } from '@polar-sh/sdk' +import * as d3 from 'd3' +import { GeistMono } from 'geist/font/mono' +import { useCallback, useEffect, useMemo, useState } from 'react' + +const primaryColor = 'rgb(0 98 255)' +const primaryColorFaded = 'rgba(0, 98, 255, 0.3)' +const gradientId = 'chart-gradient' +const createAreaGradient = (id: string) => { + // Create a element + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs') + + // Create a element + const linearGradient = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'linearGradient', + ) + linearGradient.setAttribute('id', id) + linearGradient.setAttribute('gradientTransform', 'rotate(90)') + + // Create the first element + const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop') + stop1.setAttribute('offset', '0%') + stop1.setAttribute('stop-color', primaryColorFaded) + stop1.setAttribute('stop-opacity', '0.5') + + // Create the second element + const stop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop') + stop2.setAttribute('offset', '100%') + stop2.setAttribute('stop-color', primaryColorFaded) + stop2.setAttribute('stop-opacity', '0') + + // Append the elements to the element + linearGradient.appendChild(stop1) + linearGradient.appendChild(stop2) + + // Append the element to the element + defs.appendChild(linearGradient) + + return defs +} + +class Callback extends Plot.Dot { + private callbackFunction: (index: number | undefined) => void + + public constructor( + data: Plot.Data, + options: Plot.DotOptions, + callbackFunction: (data: any) => void, + ) { + // @ts-ignore + super(data, options) + this.callbackFunction = callbackFunction + } + + // @ts-ignore + public render( + index: number[], + _scales: Plot.ScaleFunctions, + _values: Plot.ChannelValues, + _dimensions: Plot.Dimensions, + _context: Plot.Context, + _next?: Plot.RenderFunction, + ): SVGElement | null { + if (index.length) { + this.callbackFunction(index[0]) + } + return null + } +} + +const getTicks = (timestamps: Date[], maxTicks: number = 10): Date[] => { + const step = Math.ceil(timestamps.length / maxTicks) + return timestamps.filter((_, index) => index % step === 0) +} + +const getTickFormat = ( + interval: Interval, + ticks: Date[], +): ((t: Date, i: number) => any) | string => { + switch (interval) { + case Interval.HOUR: + return (t: Date, i: number) => { + const previousDate = ticks[i - 1] + if (!previousDate || previousDate.getDate() < t.getDate()) { + return d3.timeFormat('%a %H:%M')(t) + } + return d3.timeFormat('%H:%M')(t) + } + case Interval.DAY: + return '%b %d' + case Interval.WEEK: + return '%b %d' + case Interval.MONTH: + return '%b %y' + case Interval.YEAR: + return '%Y' + } +} + +interface ChartProps { + data: { + timestamp: Date + value: number + }[] + interval: Interval + metric: Metric + height?: number + maxTicks?: number + onDataIndexHover?: (index: number | undefined) => void +} + +export const Chart: React.FC = ({ + data, + interval, + metric, + height: _height, + maxTicks: _maxTicks, + onDataIndexHover, +}) => { + const [width, setWidth] = useState(0) + const height = useMemo(() => _height || 400, [_height]) + const maxTicks = useMemo(() => _maxTicks || 10, [_maxTicks]) + + const timestamps = useMemo( + () => data.map(({ timestamp }) => timestamp), + [data], + ) + const ticks = useMemo( + () => getTicks(timestamps, maxTicks), + [timestamps, maxTicks], + ) + const valueFormatter = useMemo(() => getValueFormatter(metric), [metric]) + + const [containerRef, setContainerRef] = useState(null) + + useEffect(() => { + const resizeObserver = new ResizeObserver((_entries) => { + if (containerRef) { + setWidth(containerRef.clientWidth ?? 0) + } + }) + + if (containerRef) { + resizeObserver.observe(containerRef) + } + + return () => { + if (containerRef) { + resizeObserver.unobserve(containerRef) + } + } + }, [containerRef]) + + const onMouseLeave = useCallback(() => { + if (onDataIndexHover) { + onDataIndexHover(undefined) + } + }, [onDataIndexHover]) + + useEffect(() => { + if (!containerRef) { + return + } + + const plot = Plot.plot({ + style: { + background: 'none', + }, + width, + height, + marks: [ + () => createAreaGradient(gradientId), + Plot.axisX({ + tickFormat: getTickFormat(interval, ticks), + ticks, + label: null, + stroke: 'none', + fontFamily: GeistMono.style.fontFamily, + }), + Plot.axisY({ + tickFormat: valueFormatter, + label: null, + stroke: 'none', + fontFamily: GeistMono.style.fontFamily, + }), + Plot.lineY(data, { + x: 'timestamp', + y: metric.slug, + stroke: 'currentColor', + strokeWidth: 1, + }), + Plot.ruleX(data, { + x: 'timestamp', + stroke: 'currentColor', + strokeWidth: 1, + strokeOpacity: 0.1, + }), + ], + }) + containerRef.append(plot) + + return () => plot.remove() + }, [ + data, + metric, + containerRef, + interval, + ticks, + valueFormatter, + width, + height, + onDataIndexHover, + ]) + + return ( +
+ ) +} diff --git a/clients/apps/web/src/components/Pitch/Console.tsx b/clients/apps/web/src/components/Pitch/Console.tsx new file mode 100644 index 0000000000..7120ccab14 --- /dev/null +++ b/clients/apps/web/src/components/Pitch/Console.tsx @@ -0,0 +1,25 @@ +import { twMerge } from 'tailwind-merge' + +export interface ConsoleProps { + className?: string + input?: string + output?: string +} + +export const Console = ({ className, input, output }: ConsoleProps) => { + return ( +
+
+ Polar VM +
+
+
+          {'$ ' + input}
+          {output}
+        
+
+
+ ) +} diff --git a/clients/apps/web/src/components/Pitch/Footer.tsx b/clients/apps/web/src/components/Pitch/Footer.tsx new file mode 100644 index 0000000000..4ae5eb4b45 --- /dev/null +++ b/clients/apps/web/src/components/Pitch/Footer.tsx @@ -0,0 +1,92 @@ +import { useEffect } from 'react' +import { twMerge } from 'tailwind-merge' +import { Button } from './Button' +import { sections } from './Navigation' + +export const Footer = ({ className }: { className?: string }) => { + return ( +
+ + + + +
+ ) +} + +const NavigationLegend = () => { + return ( +
+
+ + +
+ Navigate +
+ ) +} + +const SectionsLegend = () => { + return ( +
+
+ {sections.map((_, index) => ( + + ))} +
+ Sections +
+ ) +} + +const OpenSourceLegend = () => { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'o') { + window.open('https://github.com/polarsource', '_blank') + } + } + + document.addEventListener('keydown', handleKeyDown) + + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, []) + + return ( +
+
+ +
+ Open Source +
+ ) +} + +const ContactUsLegend = () => { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'm') { + window.open('mailto:birk@polar.sh', '_blank') + } + } + + document.addEventListener('keydown', handleKeyDown) + + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, []) + + return ( +
+
+ +
+ Contact Us +
+ ) +} diff --git a/clients/apps/web/src/components/Pitch/Link.tsx b/clients/apps/web/src/components/Pitch/Link.tsx new file mode 100644 index 0000000000..45ae23b7a2 --- /dev/null +++ b/clients/apps/web/src/components/Pitch/Link.tsx @@ -0,0 +1,23 @@ +import NextLink from 'next/link' +import { ComponentProps } from 'react' +import { twMerge } from 'tailwind-merge' + +export interface LinkProps extends ComponentProps { + variant?: 'primary' | 'ghost' +} + +export const Link = ({ variant = 'primary', ...props }: LinkProps) => { + const primaryClassName = 'border-b' + const ghostClassName = 'border-none' + + return ( + + ) +} diff --git a/clients/apps/web/src/components/Pitch/Navigation.tsx b/clients/apps/web/src/components/Pitch/Navigation.tsx new file mode 100644 index 0000000000..75c59b8520 --- /dev/null +++ b/clients/apps/web/src/components/Pitch/Navigation.tsx @@ -0,0 +1,50 @@ +export const sections = [ + { + title: '00. Index', + href: '/pitch', + }, + { + title: '01. Usage Based Future', + href: '/pitch/what', + }, + { + title: '02. Why', + href: '/pitch/why', + }, + { + title: '03. Polar 2.0', + href: '/pitch/how', + }, + { + title: '04. Open Source', + href: '/pitch/us', + }, + { + title: '05. Team', + href: '/pitch/team', + }, + { + title: '06. Investors', + href: '/pitch/investors', + }, +] + +export const PitchNavigation = ({ activeIndex }: { activeIndex: number }) => { + return ( +
+ Polar Software Inc. +
    + {sections.map((section, index) => ( +
  • + {section.title} +
  • + ))} +
+
+ ) +} diff --git a/clients/apps/web/src/components/Pitch/sections/IndexSection.tsx b/clients/apps/web/src/components/Pitch/sections/IndexSection.tsx new file mode 100644 index 0000000000..71bb36c5f8 --- /dev/null +++ b/clients/apps/web/src/components/Pitch/sections/IndexSection.tsx @@ -0,0 +1,32 @@ +import { Console } from '../Console' +import { Link } from '../Link' + +export const IndexSection = () => { + return ( +
+
+

00. Index

+

Integrating payments is a mess

+

+ What used to be a simple way to pay for things has become a complex + mess. +

+

+ Software as a Service (SaaS) has become the norm, but the underlying + payment infrastructure has not evolved. +

+

+ This is why we are building Polar 2.0, payment infrastructure for the + 21st century. +

+ What we are building → +
+ + +
+ ) +} diff --git a/clients/apps/web/src/components/Pitch/sections/InvestorsSection.tsx b/clients/apps/web/src/components/Pitch/sections/InvestorsSection.tsx new file mode 100644 index 0000000000..ea16d2ef7b --- /dev/null +++ b/clients/apps/web/src/components/Pitch/sections/InvestorsSection.tsx @@ -0,0 +1,25 @@ +import { Link } from '../Link' + +export const InvestorsSection = () => { + return ( +
+
+

06. Investors

+

Our Angels

+

+ What used to be a simple way to pay for things has become a complex + mess. +

+

+ Software as a Service (SaaS) has become the norm, but the underlying + payment infrastructure has not evolved. +

+

+ This is why we are building Polar 2.0, payment infrastructure for the + 21st century. +

+ Why → +
+
+ ) +} diff --git a/clients/apps/web/src/components/Pitch/sections/Polar20.tsx b/clients/apps/web/src/components/Pitch/sections/Polar20.tsx new file mode 100644 index 0000000000..a69b5d4678 --- /dev/null +++ b/clients/apps/web/src/components/Pitch/sections/Polar20.tsx @@ -0,0 +1,25 @@ +import { Link } from '../Link' + +export const Polar20Section = () => { + return ( +
+
+

03. Polar 2.0

+

The future of payments is usage based

+

+ What used to be a simple way to pay for things has become a complex + mess. +

+

+ Software as a Service (SaaS) has become the norm, but the underlying + payment infrastructure has not evolved. +

+

+ This is why we are building Polar 2.0, payment infrastructure for the + 21st century. +

+ Why → +
+
+ ) +} diff --git a/clients/apps/web/src/components/Pitch/sections/TeamSection.tsx b/clients/apps/web/src/components/Pitch/sections/TeamSection.tsx new file mode 100644 index 0000000000..086a273e53 --- /dev/null +++ b/clients/apps/web/src/components/Pitch/sections/TeamSection.tsx @@ -0,0 +1,67 @@ +import Image from 'next/image' +import { Link } from '../Link' + +const team = [ + { + name: 'Birk Jernström', + title: 'Founder & Software Engineer', + image: '/assets/team/birk.png', + }, + { + name: 'Francois Voron', + title: 'Software Engineer', + image: '/assets/team/francois.png', + }, + { + name: 'Emil Widlund', + title: 'Design Engineer', + image: '/assets/team/emil.png', + }, +] + +export const TeamSection = () => { + return ( +
+
+

05. Team

+

We are hiring

+

+ What used to be a simple way to pay for things has become a complex + mess. +

+

+ Software as a Service (SaaS) has become the norm, but the underlying + payment infrastructure has not evolved. +

+

+ This is why we are building Polar 2.0, payment infrastructure for the + 21st century. +

+ Join Us → +
+
+ {team.map((profile) => ( + + ))} +
+
+ ) +} + +interface ProfileProps { + name: string + title: string + image: string +} + +const Profile = ({ name, title, image }: ProfileProps) => { + return ( +
+ {name} +
+

{name}

+

{title}

+
+
+ ) +} diff --git a/clients/apps/web/src/components/Pitch/sections/UsageBasedSection.tsx b/clients/apps/web/src/components/Pitch/sections/UsageBasedSection.tsx new file mode 100644 index 0000000000..e23e4679c1 --- /dev/null +++ b/clients/apps/web/src/components/Pitch/sections/UsageBasedSection.tsx @@ -0,0 +1,53 @@ +import { MetricType } from '@polar-sh/sdk' +import { Chart } from '../Chart' +import { Link } from '../Link' + +export const UsageBasedSection = () => { + return ( +
+
+

01. Usage Based

+

The future of payments is usage based

+

+ What used to be a simple way to pay for things has become a complex + mess. +

+

+ Software as a Service (SaaS) has become the norm, but the underlying + payment infrastructure has not evolved. +

+

+ This is why we are building Polar 2.0, payment infrastructure for the + 21st century. +

+ Why → +
+ { + const getLastMonthValues = () => { + const values = [] + for (let i = 31; i >= 0; i--) { + values.push({ + timestamp: new Date( + new Date().setDate(new Date().getDate() - i), + ), + value: Math.floor(Math.random() * 500), + }) + } + return values + } + + return getLastMonthValues() + })(), + ]} + interval="day" + metric={{ + slug: 'value', + display_name: 'Value', + type: MetricType.SCALAR, + }} + /> +
+ ) +} diff --git a/clients/apps/web/src/components/Pitch/sections/WhySection.tsx b/clients/apps/web/src/components/Pitch/sections/WhySection.tsx new file mode 100644 index 0000000000..131e21f60d --- /dev/null +++ b/clients/apps/web/src/components/Pitch/sections/WhySection.tsx @@ -0,0 +1,25 @@ +import { Link } from '../Link' + +export const WhySection = () => { + return ( +
+
+

02. Why Polar

+

Why

+

+ What used to be a simple way to pay for things has become a complex + mess. +

+

+ Software as a Service (SaaS) has become the norm, but the underlying + payment infrastructure has not evolved. +

+

+ This is why we are building Polar 2.0, payment infrastructure for the + 21st century. +

+ Why → +
+
+ ) +} diff --git a/clients/apps/web/src/components/Pitch/useArrowFocus.ts b/clients/apps/web/src/components/Pitch/useArrowFocus.ts new file mode 100644 index 0000000000..0cb909151b --- /dev/null +++ b/clients/apps/web/src/components/Pitch/useArrowFocus.ts @@ -0,0 +1,35 @@ +'use client' + +import { useEffect } from 'react' + +interface UseArrowFocusProps { + onLeft: () => void + onRight: () => void + onNumberPress: (number: number) => void +} + +export const useArrowFocus = ({ + onLeft, + onRight, + onNumberPress, +}: UseArrowFocusProps) => { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowLeft') { + onLeft() + } else if (event.key === 'ArrowRight') { + onRight() + } + + if (event.key.match(/^\d$/)) { + onNumberPress(parseInt(event.key)) + } + } + + window.addEventListener('keydown', handleKeyDown) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [onLeft, onRight]) +}