diff --git a/apps/developer-hub/src/components/MinimalPlayground/feeds.ts b/apps/developer-hub/src/components/MinimalPlayground/feeds.ts new file mode 100644 index 0000000000..55a1de610f --- /dev/null +++ b/apps/developer-hub/src/components/MinimalPlayground/feeds.ts @@ -0,0 +1,51 @@ +export type LandingFeed = { + lazerId: number; + symbol: string; + label: string; + assetClass: string; + exponent: number; + displayPrecision: number; +}; + +export const LANDING_FEEDS: LandingFeed[] = [ + { + lazerId: 1, + symbol: "Crypto.BTC/USD", + label: "BTC/USD", + assetClass: "Crypto", + exponent: -8, + displayPrecision: 2, + }, + { + lazerId: 922, + symbol: "Equity.US.AAPL/USD", + label: "AAPL", + assetClass: "Equity", + exponent: -5, + displayPrecision: 2, + }, + { + lazerId: 1398, + symbol: "Equity.US.SPY/USD", + label: "SPY", + assetClass: "Equity", + exponent: -5, + displayPrecision: 2, + }, + { + lazerId: 327, + symbol: "FX.EUR/USD", + label: "EUR/USD", + assetClass: "FX", + exponent: -5, + displayPrecision: 4, + }, + { + lazerId: 346, + symbol: "Metal.XAU/USD", + label: "XAU/USD", + assetClass: "Metal", + exponent: -3, + displayPrecision: 2, + }, +]; diff --git a/apps/developer-hub/src/components/MinimalPlayground/index.module.scss b/apps/developer-hub/src/components/MinimalPlayground/index.module.scss new file mode 100644 index 0000000000..6a32173c70 --- /dev/null +++ b/apps/developer-hub/src/components/MinimalPlayground/index.module.scss @@ -0,0 +1,175 @@ +@use "@pythnetwork/component-library/theme"; + +.section { + margin-top: theme.spacing(-10); + + @include theme.max-width; +} + +.inner { + display: flex; + flex-direction: column; + gap: theme.spacing(6); + max-width: 64rem; + margin-left: auto; + margin-right: auto; + width: 100%; +} + +.header { + display: flex; + flex-direction: column; + gap: theme.spacing(2); + max-width: 40rem; +} + +.title { + @include theme.text("xl", "semibold"); + + color: theme.color("heading"); + margin: 0; +} + +.layout { + display: grid; + gap: theme.spacing(4); + grid-template-columns: 1fr; + align-items: stretch; + + @include theme.breakpoint("md") { + grid-template-columns: 14rem 1fr; + } +} + +.feedList { + display: flex; + flex-direction: column; + gap: theme.spacing(1); + margin: 0; + padding: theme.spacing(2); + list-style: none; + background: theme.color("background", "secondary"); + border: 1px solid theme.color("border"); + border-radius: theme.border-radius("xl"); +} + +.feedItem { + list-style: none; +} + +.feedRow { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: theme.spacing(2) theme.spacing(3); + background: transparent; + border: 1px solid transparent; + border-radius: theme.border-radius("lg"); + color: theme.color("paragraph"); + font-family: inherit; + text-align: left; + cursor: pointer; + transition: + background 120ms ease, + color 120ms ease; + + @include theme.text("sm", "medium"); + + &:hover { + background: light-dark( + theme.pallette-color("white"), + theme.pallette-color("steel", 800) + ); + color: theme.color("heading"); + } + + &:focus-visible { + outline: 2px solid theme.color("focus"); + outline-offset: 2px; + } +} + +.feedRowActive { + background: light-dark( + theme.pallette-color("violet", 50), + theme.pallette-color("steel", 700) + ); + color: theme.color("heading"); + + &:hover { + background: light-dark( + theme.pallette-color("violet", 50), + theme.pallette-color("steel", 700) + ); + } +} + +.terminal { + display: flex; + flex-direction: column; + background: theme.pallette-color("steel", 950); + border: 1px solid theme.color("border"); + border-radius: theme.border-radius("xl"); + overflow: hidden; + min-height: 13rem; +} + +.terminalHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: theme.spacing(2); + padding: theme.spacing(3) theme.spacing(5); + background: theme.color("background", "secondary"); + border-bottom: 1px solid theme.color("border"); +} + +.terminalLabel { + @include theme.text("xs", "medium"); + + color: light-dark( + theme.pallette-color("stone", 500), + theme.pallette-color("steel", 400) + ); + text-transform: uppercase; + letter-spacing: theme.letter-spacing("wider"); +} + +.terminalLabelLive { + color: light-dark( + theme.pallette-color("emerald", 600), + theme.pallette-color("emerald", 400) + ); +} + +.terminalActions { + display: flex; + align-items: center; + gap: theme.spacing(2); +} + + +.editorContainer { + flex: 1; + min-height: 10rem; + overflow: hidden; +} + +.editorLoading { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: theme.spacing(4); + color: theme.pallette-color("steel", 400); + font-family: theme.font-family("monospace"); + + @include theme.text("sm", "normal"); +} + +.ctaRow { + display: flex; + flex-wrap: wrap; + gap: theme.spacing(3); +} diff --git a/apps/developer-hub/src/components/MinimalPlayground/index.tsx b/apps/developer-hub/src/components/MinimalPlayground/index.tsx new file mode 100644 index 0000000000..e1e30882d4 --- /dev/null +++ b/apps/developer-hub/src/components/MinimalPlayground/index.tsx @@ -0,0 +1,316 @@ +"use client"; + +import type { OnMount } from "@monaco-editor/react"; +import { Copy } from "@phosphor-icons/react/dist/ssr/Copy"; +import { Play } from "@phosphor-icons/react/dist/ssr/Play"; +import { Stop } from "@phosphor-icons/react/dist/ssr/Stop"; +import { Button } from "@pythnetwork/component-library/Button"; +import { useCopy } from "@pythnetwork/component-library/useCopy"; +import { clsx } from "clsx"; +import dynamic from "next/dynamic"; +import type { KeyboardEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useStreamExecution } from "../Playground/hooks/use-stream-execution"; +import type { PlaygroundConfig } from "../Playground/types"; +import type { LandingFeed } from "./feeds"; +import { LANDING_FEEDS } from "./feeds"; +import styles from "./index.module.scss"; + +const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { + ssr: false, + loading: () =>
Loading editor…
, +}); + +type MonacoEditorInstance = Parameters[0]; + +const DEFAULT_FEED: LandingFeed = LANDING_FEEDS[0]!; + +const buildWscatCommand = (feed: LandingFeed): string => { + const subscribe = JSON.stringify({ + type: "subscribe", + subscriptionId: 1, + priceFeedIds: [feed.lazerId], + properties: ["price"], + formats: ["solana"], + channel: "fixed_rate@200ms", + parsed: true, + }); + return [ + "$ wscat -c wss://pyth-lazer.dourolabs.app/v1/stream \\", + ' -H "Authorization: Bearer $PYTH_PRO_KEY"', + "", + `> ${subscribe}`, + ].join("\n"); +}; + +const buildConfig = (feed: LandingFeed): PlaygroundConfig => ({ + accessToken: "", + priceFeedIds: [feed.lazerId], + properties: ["price"], + formats: ["solana"], + channel: "fixed_rate@200ms", + deliveryFormat: "json", + jsonBinaryEncoding: "hex", + parsed: true, +}); + +type StreamPayload = { + type?: string; + error?: string; + parsed?: { + priceFeeds?: { priceFeedId?: number; price?: string | number }[]; + }; +}; + +const formatStreamLine = ( + event: string, + data: unknown, + feed: LandingFeed, +): string | undefined => { + if (event === "connected") return "# Connected"; + if (event === "subscribed") return `# Subscribed to ${feed.symbol}`; + if (event === "close") return "# Closed"; + if (event === "error") { + const errData = data as { error?: string; message?: string } | undefined; + return `! ${errData?.error ?? errData?.message ?? "stream error"}`; + } + if (event === "message") { + const payload = data as StreamPayload | undefined; + if (payload?.type === "subscriptionError") { + return `! ${payload.error ?? "subscription error"}`; + } + const rawPrice = payload?.parsed?.priceFeeds?.[0]?.price; + if (rawPrice === undefined || rawPrice === null) return undefined; + const usd = Number(rawPrice) * 10 ** feed.exponent; + if (!Number.isFinite(usd)) return undefined; + const formatted = usd.toLocaleString("en-US", { + minimumFractionDigits: feed.displayPrecision, + maximumFractionDigits: feed.displayPrecision, + }); + return `< ${formatted}`; + } + return undefined; +}; + +export const MinimalPlayground = () => { + const [selectedFeed, setSelectedFeed] = useState(DEFAULT_FEED); + const { status, messages, startStream, stopStream } = useStreamExecution(); + const editorRef = useRef(undefined); + + const command = useMemo( + () => buildWscatCommand(selectedFeed), + [selectedFeed], + ); + const { isCopied, copy } = useCopy(command); + + const isStreaming = status === "connecting" || status === "connected"; + + const handleSelect = useCallback( + (feed: LandingFeed) => { + stopStream(); + setSelectedFeed(feed); + }, + [stopStream], + ); + + const handleRun = useCallback(() => { + if (isStreaming) { + stopStream(); + } else { + startStream(buildConfig(selectedFeed)); + } + }, [isStreaming, selectedFeed, startStream, stopStream]); + + const handleRowKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key !== "Enter") return; + event.preventDefault(); + if (isStreaming) { + stopStream(); + return; + } + const lazerId = Number(event.currentTarget.dataset.lazerId); + const feed = LANDING_FEEDS.find((f) => f.lazerId === lazerId); + if (feed) { + setSelectedFeed(feed); + startStream(buildConfig(feed)); + } + }, + [isStreaming, startStream, stopStream], + ); + + const formattedLines = useMemo( + () => + messages + .map((m) => ({ + id: m.id, + line: formatStreamLine(m.event, m.data, selectedFeed), + })) + .filter((item): item is { id: string; line: string } => + item.line !== undefined, + ), + [messages, selectedFeed], + ); + + const hasOutput = formattedLines.length > 0; + const showIdleHint = !isStreaming && !hasOutput; + + const editorValue = useMemo(() => { + let text = command; + if (hasOutput) { + text += "\n\n" + formattedLines.map((f) => f.line).join("\n"); + } + if (showIdleHint) { + text += "\n\n# Press Run or Enter to stream live updates"; + } + return text; + }, [command, hasOutput, formattedLines, showIdleHint]); + + useEffect(() => { + const editor = editorRef.current; + if (editor && hasOutput) { + const model = editor.getModel(); + if (model) { + editor.revealLine(model.getLineCount()); + } + } + }, [formattedLines.length, hasOutput]); + + const handleEditorMount = useCallback((editor) => { + editorRef.current = editor; + }, []); + + const statusLabel = (() => { + if (status === "connecting") return "Connecting…"; + if (status === "connected") return "● Live"; + if (status === "error") return "Error"; + if (status === "closed" && hasOutput) return "Closed"; + return "wscat"; + })(); + + return ( +
+
+
+

Try the price feed

+
+ +
+
    + {LANDING_FEEDS.map((feed) => { + const isActive = feed.lazerId === selectedFeed.lazerId; + return ( +
  • + +
  • + ); + })} +
+ +
+
+ + {statusLabel} + +
+ + +
+
+
+ +
+
+
+ +
+ + +
+
+
+ ); +}; diff --git a/apps/developer-hub/src/components/Pages/Homepage/index.module.scss b/apps/developer-hub/src/components/Pages/Homepage/index.module.scss index 4a691f00d8..9e0e7a1680 100644 --- a/apps/developer-hub/src/components/Pages/Homepage/index.module.scss +++ b/apps/developer-hub/src/components/Pages/Homepage/index.module.scss @@ -19,13 +19,20 @@ } .sectionHeroContent { + --max-width-padding: #{theme.spacing(4)}; + display: flex; flex-direction: column; gap: theme.spacing(10); - padding-top: theme.spacing(18); - padding-bottom: theme.spacing(18); - - @include theme.max-width; + padding: theme.spacing(10) var(--max-width-padding); + width: 100%; + max-width: 64rem; + margin-left: auto; + margin-right: auto; + + @include theme.breakpoint("sm") { + --max-width-padding: #{theme.spacing(6)}; + } @include theme.breakpoint("lg") { flex-direction: row; @@ -65,7 +72,18 @@ } .sectionProducts { - @include theme.max-width; + --max-width-padding: #{theme.spacing(4)}; + + padding-left: var(--max-width-padding); + padding-right: var(--max-width-padding); + width: 100%; + max-width: 64rem; + margin-left: auto; + margin-right: auto; + + @include theme.breakpoint("sm") { + --max-width-padding: #{theme.spacing(6)}; + } } .sectionHeaderTitle { @@ -91,10 +109,66 @@ gap: theme.spacing(8); @include theme.breakpoint("lg") { - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.sectionEntropy { + --max-width-padding: #{theme.spacing(4)}; + + padding-left: var(--max-width-padding); + padding-right: var(--max-width-padding); + width: 100%; + max-width: 64rem; + margin-left: auto; + margin-right: auto; + + @include theme.breakpoint("sm") { + --max-width-padding: #{theme.spacing(6)}; + } +} + +.entropyCard { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: theme.spacing(4); + padding: theme.spacing(6) theme.spacing(8); + background: theme.color("background", "secondary"); + border: 1px solid theme.color("border"); + border-radius: theme.border-radius("xl"); + + @include theme.breakpoint("md") { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: theme.spacing(8); } } +.entropyContent { + display: flex; + flex-direction: column; + gap: theme.spacing(1); +} + +.entropyTitle { + @include theme.text("xl", "semibold"); + + color: theme.color("heading"); + margin: 0; +} + +.entropyDescription { + @include theme.text("base", "normal"); + + color: light-dark( + theme.pallette-color("stone", 500), + theme.pallette-color("steel", 400) + ); + margin: 0; +} + .productsCardWrapper { position: relative; } diff --git a/apps/developer-hub/src/components/Pages/Homepage/index.tsx b/apps/developer-hub/src/components/Pages/Homepage/index.tsx index 6a075e40d1..ba78db6b81 100644 --- a/apps/developer-hub/src/components/Pages/Homepage/index.tsx +++ b/apps/developer-hub/src/components/Pages/Homepage/index.tsx @@ -1,3 +1,6 @@ +import { Button } from "@pythnetwork/component-library/Button"; + +import { MinimalPlayground } from "../../MinimalPlayground"; import { ProductCard } from "../../ProductCard"; import styles from "./index.module.scss"; import ResourcesForBuildersImage from "./resources-for-builders.svg"; @@ -19,13 +22,15 @@ export const Homepage = () => { + +
-

Products

+

Global Pricing Layer

- Connect to the global market data and randomness layer. + Real-time price feeds for crypto, equities, FX, and metals.

- {products.map((product: ProductCardConfig) => ( + {pricingLayerProducts.map((product: ProductCardConfig) => (
{
+
+
+
+

Entropy

+

+ Secure, verifiable randomness for EVM-based smart contracts. +

+
+ +
+
+
} isHighlight @@ -116,7 +135,7 @@ function GradientDivider() { return
; } -const products: ProductCardConfig[] = [ +const pricingLayerProducts: ProductCardConfig[] = [ { description: "Subscription-based price data for institutions and advanced use cases. Previously known as Lazer.", @@ -163,29 +182,6 @@ const products: ProductCardConfig[] = [ ], title: "Pyth Core", }, - { - description: - "Secure, Verifiable Random Number Generator for EVM-based smart contracts.", - features: [ - { label: "On-chain randomness" }, - { label: "Verifiable results" }, - { label: "Pay in native token" }, - { label: "Supports 20+ EVM chains" }, - ], - href: "/entropy", - quickLinks: [ - { - href: "/entropy/chainlist", - label: "Chainlist", - }, - { href: "/entropy/protocol-design", label: "Protocol Design" }, - { - href: "https://entropy-explorer.pyth.network/", - label: "Entropy Explorer", - }, - ], - title: "Entropy", - }, ]; type ProductCardConfig = { diff --git a/apps/developer-hub/src/components/Pages/Homepage/section.module.scss b/apps/developer-hub/src/components/Pages/Homepage/section.module.scss index 727fabff5f..3943e0b788 100644 --- a/apps/developer-hub/src/components/Pages/Homepage/section.module.scss +++ b/apps/developer-hub/src/components/Pages/Homepage/section.module.scss @@ -8,7 +8,7 @@ .sectionContent { margin: 0 auto; width: 100%; - max-width: theme.$max-width; + max-width: 64rem; } .sectionHeader {