diff --git a/src/components/defi.tsx b/src/components/defi.tsx index a1e3f76e..2ccabc80 100644 --- a/src/components/defi.tsx +++ b/src/components/defi.tsx @@ -906,6 +906,7 @@ const Defi: React.FC = () => { if (tabParam !== activeTab) { const params = new URLSearchParams(searchParams.toString()); params.set("tab", activeTab); + params.delete("asset"); router.replace(`/defi?${params.toString()}`, { scroll: false }); } }, [activeTab, router, searchParams]); @@ -930,6 +931,26 @@ const Defi: React.FC = () => { setShowMoreFilters(false); setShowStablesOnly(false); }, [activeTab]); + + const prevAssetParamRef = React.useRef(null); + React.useEffect(() => { + const raw = searchParams.get("asset"); + const validLST: AssetFilter[] = [ + "xSTRK", + "xWBTC", + "xtBTC", + "xLBTC", + "xsBTC", + ]; + if (raw && validLST.map(lst => lst.toLowerCase()).includes(raw.toLowerCase() as AssetFilter)) { + const lst = validLST.find(lst => lst.toLowerCase() === raw.toLowerCase()); + setSelectedAsset(lst as AssetFilter); + } else if (prevAssetParamRef.current !== null && raw === null) { + setSelectedAsset("all"); + } + prevAssetParamRef.current = raw; + }, [searchParams]); + const [pendingProtocolLink, setPendingProtocolLink] = useState( null, ); diff --git a/src/components/stake-share-success.tsx b/src/components/stake-share-success.tsx new file mode 100644 index 00000000..fe5984de --- /dev/null +++ b/src/components/stake-share-success.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { motion, useMotionValue } from "framer-motion"; +import React from "react"; + +import type { LSTAssetConfig } from "@/constants"; +import { cn } from "@/lib/utils"; + +import { Icons } from "./Icons"; + +function stakeShareUnderlyingIcon(symbol: string, className: string) { + switch (symbol) { + case "STRK": + return ; + case "WBTC": + return ; + case "tBTC": + return ; + case "LBTC": + return ; + case "solvBTC": + return ; + default: + return ; + } +} + +export function stakeShareLstIcon(lstSymbol: string, className: string) { + switch (lstSymbol) { + case "xSTRK": + return ; + case "xWBTC": + return ; + case "xtBTC": + return ; + case "xLBTC": + return ; + case "xsBTC": + return ; + default: + return ; + } +} + +/** Stake modal: underlying is always native STRK when category is STRK (not LST / Endur mark). */ +export function stakeShareModalUnderlyingIcon( + symbol: string, + category: LSTAssetConfig["CATEGORY"], + className: string, +) { + if (category === "STRK") { + return ; + } + return stakeShareUnderlyingIcon(symbol, className); +} + +/** Share-dialog intro duration (must match phase timeout in stake.tsx). */ +export const STAKE_SHARE_INTRO_DURATION_S = 5; + +function smoothstep01(t0: number, t1: number, t: number) { + if (t <= t0) return 0; + if (t >= t1) return 1; + const u = (t - t0) / (t1 - t0); + return u * u * (3 - 2 * u); +} + +function rocketAngularVelocity01(t: number): number { + const envelope = Math.sin(Math.PI * t) ** 2; + let bump: number; + if (t <= 0.5) { + const u = t / 0.5; + bump = Math.log(1 + 16 * u) / Math.log(17); + } else { + const u = (1 - t) / 0.5; + bump = Math.log(1 + 16 * u) / Math.log(17); + } + return envelope * bump; +} + +function buildRotationAngleTable(totalDeg: number, steps: number) { + const dt = 1 / steps; + const v = new Float64Array(steps + 1); + for (let i = 0; i <= steps; i++) { + v[i] = rocketAngularVelocity01(i / steps); + } + let sumV = 0; + for (let i = 0; i < steps; i++) { + sumV += 0.5 * (v[i] + v[i + 1]) * dt; + } + const angles = new Float64Array(steps + 1); + let acc = 0; + for (let i = 1; i <= steps; i++) { + acc += 0.5 * (v[i - 1] + v[i]) * dt; + angles[i] = (totalDeg * acc) / sumV; + } + return angles; +} + +function sampleAngleAt(angles: Float64Array, steps: number, t: number) { + if (t <= 0) return 0; + if (t >= 1) return angles[steps]; + const x = t * steps; + const i = Math.min(steps - 1, Math.floor(x)); + const f = x - i; + return angles[i] + (angles[i + 1] - angles[i]) * f; +} + +export function StakeShareIntroAnimation({ + symbol, + lstSymbol, + assetCategory, + className, +}: { + symbol: string; + lstSymbol: string; + assetCategory: LSTAssetConfig["CATEGORY"]; + className?: string; +}) { + const iconWrapperClass = + "flex size-full max-h-[min(220px,58vmin)] max-w-[min(220px,58vmin)] items-center justify-center [&_svg]:h-full [&_svg]:w-full [&_svg]:max-h-full [&_svg]:max-w-full"; + + const rotate = useMotionValue(0); + const underlyingOpacity = useMotionValue(1); + const lstOpacity = useMotionValue(0); + + const angles = React.useMemo( + () => buildRotationAngleTable(18 * 360, 960), + [], + ); + const steps = angles.length - 1; + + React.useLayoutEffect(() => { + rotate.set(0); + underlyingOpacity.set(1); + lstOpacity.set(0); + }, [rotate, underlyingOpacity, lstOpacity]); + + React.useEffect(() => { + const start = performance.now(); + const durationMs = STAKE_SHARE_INTRO_DURATION_S * 1000; + let raf = 0; + + const tick = (now: number) => { + const t = Math.min(1, (now - start) / durationMs); + rotate.set(sampleAngleAt(angles, steps, t)); + underlyingOpacity.set(1 - smoothstep01(0.62, 0.94, t)); + lstOpacity.set(smoothstep01(0.64, 0.97, t)); + if (t < 1) { + raf = requestAnimationFrame(tick); + } else { + rotate.set(angles[steps]); + underlyingOpacity.set(0); + lstOpacity.set(1); + } + }; + + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [angles, lstOpacity, rotate, steps, underlyingOpacity]); + + return ( +
+ + + {[0, 1, 2].map((i) => ( + + ))} + +
+
+ + + {stakeShareModalUnderlyingIcon( + symbol, + assetCategory, + "shrink-0", + )} + + + {stakeShareLstIcon(lstSymbol, "shrink-0")} + + +
+
+
+ ); +} diff --git a/src/components/stake.tsx b/src/components/stake.tsx index 6942b5b2..4ad9547f 100644 --- a/src/components/stake.tsx +++ b/src/components/stake.tsx @@ -9,7 +9,8 @@ import { } from "@starknet-react/core"; import { useAtomValue } from "jotai"; -import { AlertCircleIcon, ChevronDown, Info } from "lucide-react"; +import { motion } from "framer-motion"; +import { AlertCircleIcon, ArrowRight, ChevronDown, Info } from "lucide-react"; import { Figtree } from "next/font/google"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; @@ -69,6 +70,12 @@ import { snAPYAtom } from "@/store/staking.store"; import { Icons } from "./Icons"; import { PlatformCard } from "./platform-card"; +import { + STAKE_SHARE_INTRO_DURATION_S, + StakeShareIntroAnimation, + stakeShareLstIcon, + stakeShareModalUnderlyingIcon, +} from "./stake-share-success"; import Stats from "./stats"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; @@ -145,6 +152,9 @@ const platformConfig = (lstConfig: LSTAssetConfig) => { const Stake: React.FC = () => { const [showShareModal, setShowShareModal] = React.useState(false); + const [shareModalPhase, setShareModalPhase] = React.useState< + "intro" | "content" + >("intro"); const [showMaxedOutModal, setShowMaxedOutModal] = React.useState(false); const [selectedPlatform, setSelectedPlatform] = React.useState("none"); @@ -528,6 +538,18 @@ const Stake: React.FC = () => { return config ? yields[config.key] : null; }; + const defiEarnAssetParams = [ + "xSTRK", + "xWBTC", + "xtBTC", + "xLBTC", + "xsBTC", + ] as const; + const defiEarnWithLstHref = + (defiEarnAssetParams as readonly string[]).includes(lstConfig.LST_SYMBOL) + ? `/defi?tab=borrow&asset=${encodeURIComponent(lstConfig.LST_SYMBOL)}` + : "/defi?tab=borrow"; + React.useEffect(() => { handleTransaction("STAKE", { form, @@ -543,86 +565,184 @@ const Stake: React.FC = () => { }); }, [data?.transaction_hash, form, isPending]); + React.useEffect(() => { + if (!showShareModal) { + setShareModalPhase("intro"); + return; + } + setShareModalPhase("intro"); + const id = window.setTimeout( + () => setShareModalPhase("content"), + STAKE_SHARE_INTRO_DURATION_S * 1000, + ); + return () => window.clearTimeout(id); + }, [showShareModal]); + return (
- - - - Thank you for taking a step towards decentralizing Starknet! - - - {selectedPlatform === "trovesHyper" && ( -

- You've successfully staked your asset with
{" "} - - {" "} - Troves Vault - -

- )} - +
- While your stake is being processed, if you like Endur, do you - mind sharing on X/Twitter? - - -
- - MyAnalytics.track(AnalyticsEvents.STAKE_TWITTER_SHARE_CLICK, { - platform: selectedPlatform, - symbol: lstConfig.SYMBOL, - }) - } - url={`https://endur.fi`} - title={`Just staked my ${lstConfig.SYMBOL} on @endurfi, earning ${((activeTab === "strk" ? apy.value.strkApy : apy.value.btcApy) * 100 + (selectedPlatform !== "none" ? getPlatformYield(selectedPlatform) : 0)).toFixed(2)}% APY! 🚀 \n\n${selectedPlatform !== "none" ? `My ${lstConfig.LST_SYMBOL} is now with an additional ${getPlatformYield(selectedPlatform).toFixed(2)}% yield on ${getPlatformConfig(selectedPlatform).platform}! 📈\n\n` : ""}${lstConfig.SYMBOL !== "STRK" ? `Building the future of Bitcoin staking on Starknet` : `Laying the foundation for decentralising Starknet`} with Endur!\n\n`} - related={["endurfi", "troves", "karnotxyz"]} - style={{ - display: "flex", - alignItems: "center", - gap: ".6rem", - padding: ".5rem 2rem", - borderRadius: "12px", - backgroundColor: "#17876D", - color: "white", - textWrap: "nowrap", - }} - > - Share on - - + + Stake successful + +
- {selectedPlatform === "trovesHyper" && ( - - - - Important - - -

Your staked balance is now managed by Troves Vault.

-

- View your position and rewards on Troves.{" "} +

+ + + Thank you for taking a step towards decentralizing Starknet! + + + + + {lstConfig.SYMBOL} + + + {stakeShareModalUnderlyingIcon( + lstConfig.SYMBOL, + lstConfig.CATEGORY, + "size-8", + )} + + + + {stakeShareLstIcon(lstConfig.LST_SYMBOL, "size-8")} + + + {lstConfig.LST_SYMBOL} + + + + {selectedPlatform === "trovesHyper" && ( +

+ You've successfully staked your asset with
{" "} - Link. + {" "} + Troves Vault

- - - )} -
+ )} + {/* + If you like Endur, do you + mind sharing on X/Twitter? + */} + +
+ + MyAnalytics.track(AnalyticsEvents.STAKE_TWITTER_SHARE_CLICK, { + platform: selectedPlatform, + symbol: lstConfig.SYMBOL, + }) + } + url={`https://endur.fi`} + title={`Just staked my ${lstConfig.SYMBOL} on @endurfi, earning ${((activeTab === "strk" ? apy.value.strkApy : apy.value.btcApy) * 100 + (selectedPlatform !== "none" ? getPlatformYield(selectedPlatform) : 0)).toFixed(2)}% APY! 🚀 \n\n${selectedPlatform !== "none" ? `My ${lstConfig.LST_SYMBOL} is now with an additional ${getPlatformYield(selectedPlatform).toFixed(2)}% yield on ${getPlatformConfig(selectedPlatform).platform}! 📈\n\n` : ""}${lstConfig.SYMBOL !== "STRK" ? `Building the future of Bitcoin staking on Starknet` : `Laying the foundation for decentralising Starknet`} with Endur!\n\n`} + related={["endurfi", "troves", "karnotxyz"]} + style={{ + display: "flex", + alignItems: "center", + gap: ".6rem", + padding: ".5rem 2rem", + borderRadius: "12px", + backgroundColor: "#17876D", + color: "white", + textWrap: "nowrap", + }} + > + Share on + + + + MyAnalytics.track(AnalyticsEvents.STAKE_USE_LST_CLICK, { + lst: lstConfig.LST_SYMBOL, + }) + } + > + Use {lstConfig.LST_SYMBOL} + +
+ + {selectedPlatform === "trovesHyper" && ( + + + + Important + + +

Your staked balance is now managed by Troves Vault.

+

+ View your position and rewards on Troves.{" "} + + Link. + +

+
+
+ )} + +
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index c8e287b9..1dbb041e 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -58,9 +58,13 @@ const DialogContent = React.forwardRef< > {children} {!hideCloseIcon && ( - + Close diff --git a/src/lib/analytics-events.ts b/src/lib/analytics-events.ts index a853d5c6..6e9c8b93 100644 --- a/src/lib/analytics-events.ts +++ b/src/lib/analytics-events.ts @@ -97,6 +97,7 @@ export const AnalyticsEvents = { STAKE_EARN_COLLAPSIBLE_TOGGLE: "stake_earn_collapsible_toggle", STAKE_PLATFORM_SELECT: "stake_platform_select", STAKE_TWITTER_SHARE_CLICK: "stake_twitter_share_click", + STAKE_USE_LST_CLICK: "stake_use_lst_click", // Rewards post-claim interactions REWARDS_CLAIM_TWITTER_SHARE_CLICK: "rewards_claim_twitter_share_click",