356 lines
16 KiB
TypeScript
356 lines
16 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { Play, ArrowUpRight, Code2, Terminal, MonitorPlay, FlaskConical, PenLine, BookOpen } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
function useScrollReveal() {
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
const [visible, setVisible] = useState(false)
|
|
useEffect(() => {
|
|
const el = ref.current
|
|
if (!el) return
|
|
const obs = new IntersectionObserver(
|
|
([entry]) => { if (entry.isIntersecting) { setVisible(true); obs.disconnect() } },
|
|
{ threshold: 0.1 }
|
|
)
|
|
obs.observe(el)
|
|
return () => obs.disconnect()
|
|
}, [])
|
|
return { ref, visible }
|
|
}
|
|
|
|
function TelegramIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
|
<path d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0zm5.89 8.25-1.97 9.27c-.14.66-.54.82-1.08.51l-3-2.21-1.45 1.39c-.16.16-.3.29-.61.29l.21-3.05 5.56-5.02c.24-.21-.05-.33-.37-.12L6.3 13.66l-2.96-.92c-.64-.2-.65-.64.14-.95l11.57-4.46c.53-.19 1 .13.84.92z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function MattermostIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
|
<path d="M11.977 0C5.358 0 .008 5.35.008 11.97S5.358 23.94 11.978 23.94c6.618 0 11.969-5.35 11.969-11.97S18.596 0 11.977 0zm5.882 17.29c-.114.099-.276.131-.419.082l-5.47-1.764-3.674 2.672a.387.387 0 01-.606-.385l.553-5.784-5.214-2.858a.387.387 0 01.072-.71l14.476-4.365a.385.385 0 01.487.463l-2.878 12.175a.387.387 0 01-.307.474z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function GitHubIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function XIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.737-8.835L1.254 2.25H8.08l4.253 5.622 5.911-5.622Zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77Z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function DockerIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
|
<path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.186.186 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.186v1.887c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
interface VideoMeta {
|
|
id: string
|
|
meta: string
|
|
title: string
|
|
ariaLabel: string
|
|
start?: number
|
|
}
|
|
|
|
function YoutubeThumbnail({ video }: { video: VideoMeta }) {
|
|
const [playing, setPlaying] = useState(false)
|
|
|
|
if (playing) {
|
|
return (
|
|
<div className="relative aspect-video w-full rounded-2xl overflow-hidden bg-black">
|
|
<iframe
|
|
src={`https://www.youtube.com/embed/${video.id}?autoplay=1&rel=0&modestbranding=1${video.start ? `&start=${video.start}` : ''}`}
|
|
title={video.ariaLabel}
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
allowFullScreen
|
|
className="absolute inset-0 w-full h-full border-0"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => setPlaying(true)}
|
|
aria-label={`Play: ${video.ariaLabel}`}
|
|
className="group relative w-full aspect-video rounded-2xl overflow-hidden bg-black cursor-pointer"
|
|
>
|
|
<img
|
|
src={`https://img.youtube.com/vi/${video.id}/maxresdefault.jpg`}
|
|
alt=""
|
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/65 via-transparent to-transparent" aria-hidden="true" />
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div
|
|
className="w-16 h-16 rounded-full flex items-center justify-center transition-transform duration-300 group-hover:scale-110"
|
|
style={{ backgroundColor: 'hsl(var(--primary))', boxShadow: '0 0 0 16px hsl(var(--primary) / 0.15)' }}
|
|
>
|
|
<Play size={20} fill="white" className="ml-1" aria-hidden="true" />
|
|
</div>
|
|
</div>
|
|
<div className="absolute bottom-0 left-0 right-0 p-5">
|
|
<p className="text-white/60 text-xs font-medium">{video.meta}</p>
|
|
<p className="text-white font-semibold text-sm mt-0.5 leading-snug">{video.title}</p>
|
|
</div>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
const DOC_VIDEO: VideoMeta = {
|
|
id: 'Z78ZbPcsc3g',
|
|
meta: 'Documentary · 42 min',
|
|
title: 'My Trust in You Is Broken',
|
|
ariaLabel: 'My Trust in You Is Broken — BTCPay Server documentary',
|
|
}
|
|
|
|
const DEV_VIDEO: VideoMeta = {
|
|
id: 'dW9eSgA_dUg',
|
|
meta: 'Dev setup walkthrough',
|
|
title: 'BTCPay Server Development Setup',
|
|
ariaLabel: 'BTCPay Server development environment setup tutorial',
|
|
start: 408,
|
|
}
|
|
|
|
function ToolRow({ href, icon, label, meta }: {
|
|
href?: string
|
|
icon: React.ReactNode
|
|
label: React.ReactNode
|
|
meta?: React.ReactNode
|
|
}) {
|
|
const base = 'flex items-center gap-3 rounded-2xl border border-border/60 bg-card/50 p-3'
|
|
const inner = (
|
|
<>
|
|
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center shrink-0">{icon}</div>
|
|
<span className="font-semibold text-sm text-foreground flex-1 min-w-0">{label}</span>
|
|
{meta ?? (href && <ArrowUpRight size={13} className="text-muted-foreground group-hover:text-foreground transition-colors shrink-0" aria-hidden="true" />)}
|
|
</>
|
|
)
|
|
if (href) {
|
|
return (
|
|
<a href={href} target="_blank" rel="noopener noreferrer"
|
|
className={`${base} group hover:border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
|
|
>{inner}</a>
|
|
)
|
|
}
|
|
return <div className={base}>{inner}</div>
|
|
}
|
|
|
|
function InlineLinks({ links }: { links: { href: string; label: string }[] }) {
|
|
return (
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{links.map((link, i) => (
|
|
<span key={link.href} className="flex items-center gap-2">
|
|
{i > 0 && <span className="text-border">·</span>}
|
|
<a href={link.href} target="_blank" rel="noopener noreferrer" className="text-[11px] font-medium text-primary hover:underline">{link.label}</a>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DevToolRows() {
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
<ToolRow
|
|
icon={<GitHubIcon className="w-4 h-4 text-foreground" />}
|
|
label="Git Client"
|
|
meta={<InlineLinks links={[{ href: 'https://desktop.github.com', label: 'GitHub Desktop' }, { href: 'https://www.sourcetreeapp.com', label: 'SourceTree' }]} />}
|
|
/>
|
|
<ToolRow
|
|
href="https://dotnet.microsoft.com/download/dotnet/10.0"
|
|
icon={<Terminal size={15} className="text-foreground" />}
|
|
label=".NET 10.0 SDK"
|
|
/>
|
|
<ToolRow
|
|
href="https://www.docker.com/get-started/"
|
|
icon={<DockerIcon className="w-4 h-4 text-foreground" />}
|
|
label="Docker Desktop"
|
|
/>
|
|
<ToolRow
|
|
href="https://www.jetbrains.com/rider/"
|
|
icon={<Code2 size={15} className="text-foreground" />}
|
|
label={<>JetBrains Rider <span className="text-xs font-normal text-muted-foreground">community edition</span></>}
|
|
/>
|
|
<ToolRow
|
|
icon={<BookOpen size={15} className="text-foreground" />}
|
|
label={<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">Optional</span>}
|
|
meta={<InlineLinks links={[
|
|
{ href: 'https://docs.btcpayserver.org/Development/LocalDevelopment/', label: 'Dev Docs' },
|
|
{ href: 'https://www.youtube.com/watch?v=GWR_CcMsEV0&list=PLrIppt9ulOJ3fuAhsYkbubN_vI0nhvDUC', label: 'Playlist' },
|
|
]} />}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const STEPS = [
|
|
{
|
|
label: 'Documentary',
|
|
title: 'Watch the documentary',
|
|
description: 'Understand the mission and meet the contributors who built BTCPay Server. A 42-minute film that shows why this project matters.',
|
|
},
|
|
{
|
|
label: 'Dev Setup',
|
|
title: 'Deploy a local dev environment',
|
|
description: '',
|
|
},
|
|
{
|
|
label: 'Community',
|
|
title: 'Join the community',
|
|
description: 'Introduce yourself, ask questions, and connect with contributors who have shipped real features. Everyone started exactly where you are.',
|
|
},
|
|
{
|
|
label: 'Find Issue',
|
|
title: 'Pick an issue and ship it',
|
|
description: 'Filter by your skill and grab a good-first-issue that fits your experience level.',
|
|
},
|
|
]
|
|
|
|
function StepVisual({ index }: { index: number }) {
|
|
if (index === 0) return <YoutubeThumbnail video={DOC_VIDEO} />
|
|
|
|
if (index === 1) return <YoutubeThumbnail video={DEV_VIDEO} />
|
|
|
|
if (index === 2) {
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
<ToolRow href="https://t.me/btcpayserver" icon={<TelegramIcon className="w-4 h-4 text-foreground" />} label="Telegram" />
|
|
<ToolRow href="https://chat.btcpayserver.org" icon={<MattermostIcon className="w-4 h-4 text-foreground" />} label="Mattermost" />
|
|
<ToolRow href="https://x.com/BtcpayServer" icon={<XIcon className="w-4 h-4 text-foreground" />} label="Follow on X" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-1">
|
|
When submitting a PR
|
|
</p>
|
|
<ToolRow icon={<MonitorPlay size={15} className="text-foreground" />} label="Include a screen recording in description" />
|
|
<ToolRow icon={<FlaskConical size={15} className="text-foreground" />} label="Test locally before requesting review" />
|
|
<ToolRow icon={<PenLine size={15} className="text-foreground" />} label="Write a human-readable description" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StepRow({ step, index }: { step: typeof STEPS[number]; index: number }) {
|
|
const { ref, visible } = useScrollReveal()
|
|
const flip = index % 2 !== 0
|
|
|
|
if (index === 1) {
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={cn(
|
|
'py-14 sm:py-20 space-y-6 transition-all duration-700 ease-out',
|
|
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10',
|
|
)}
|
|
>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
|
|
<div className="hidden lg:block" />
|
|
<div className="space-y-1">
|
|
<span
|
|
className="font-display font-bold leading-none select-none block text-foreground/[0.055]"
|
|
style={{ fontSize: 'clamp(5rem, 12vw, 9rem)' }}
|
|
aria-hidden="true"
|
|
>
|
|
02
|
|
</span>
|
|
<h3 className="font-display font-bold text-2xl sm:text-3xl text-foreground leading-tight -mt-3 sm:-mt-5">
|
|
{step.title}
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 items-center">
|
|
<YoutubeThumbnail video={DEV_VIDEO} />
|
|
<DevToolRows />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={cn(
|
|
'grid grid-cols-1 lg:grid-cols-2 gap-10 lg:gap-20 py-14 sm:py-20 items-start transition-all duration-700 ease-out',
|
|
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10',
|
|
)}
|
|
>
|
|
<div className={cn('space-y-1', flip && 'lg:order-2')}>
|
|
<span
|
|
className="font-display font-bold leading-none select-none block text-foreground/[0.055]"
|
|
style={{ fontSize: 'clamp(5rem, 12vw, 9rem)' }}
|
|
aria-hidden="true"
|
|
>
|
|
{String(index + 1).padStart(2, '0')}
|
|
</span>
|
|
<div className="-mt-3 sm:-mt-5 space-y-3">
|
|
<h3 className="font-display font-bold text-2xl sm:text-3xl text-foreground leading-tight">
|
|
{step.title}
|
|
</h3>
|
|
{step.description && (
|
|
<p className="text-muted-foreground leading-relaxed max-w-sm">{step.description}</p>
|
|
)}
|
|
{index === 3 && (
|
|
<div className="pt-1">
|
|
<a
|
|
href="#issues"
|
|
className="inline-flex items-center gap-2 rounded-full px-8 h-12 text-sm font-semibold text-primary-foreground bg-primary shadow-lg shadow-primary/20 hover:shadow-primary/40 hover:bg-primary/90 transition-all duration-300 hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
>
|
|
Pick an issue
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cn(flip && 'lg:order-1')}>
|
|
<StepVisual index={index} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function ResourcesSection() {
|
|
return (
|
|
<section id="how-it-works" aria-label="Getting started" className="border-t border-border/60">
|
|
|
|
<div className="text-center pt-20 sm:pt-28 pb-6">
|
|
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-4">
|
|
How it works
|
|
</p>
|
|
<h2 className="font-display font-bold text-3xl sm:text-4xl lg:text-5xl text-foreground">
|
|
Your path to first contribution
|
|
</h2>
|
|
<p className="mt-4 text-muted-foreground max-w-sm mx-auto text-sm sm:text-base">
|
|
Four steps from curious to your first merged PR.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="divide-y divide-border/60">
|
|
{STEPS.map((step, i) => (
|
|
<StepRow key={step.label} step={step} index={i} />
|
|
))}
|
|
</div>
|
|
|
|
</section>
|
|
)
|
|
}
|