diff --git a/apps/dashboard/src/@/analytics/report.ts b/apps/dashboard/src/@/analytics/report.ts index c054a150872..0f698ef75d7 100644 --- a/apps/dashboard/src/@/analytics/report.ts +++ b/apps/dashboard/src/@/analytics/report.ts @@ -702,3 +702,21 @@ export function reportTokenRowClicked(params: { }) { posthog.capture("token row clicked", params); } + +/** + * ### Why do we need to report this event? + * - To track engagement with the year-in-review/rewind feature + * - To measure how many users view their yearly stats + * - To understand the impact of this marketing feature + * + * ### Who is responsible for this event? + * @thirdweb + */ +export function reportRewindViewed(properties: { + year: number; + totalRpcRequests: number; + totalWalletConnections: number; + totalMainnetSponsoredTransactions: number; +}) { + posthog.capture("rewind viewed", properties); +} diff --git a/apps/dashboard/src/@/api/rewind/get-year-in-review.ts b/apps/dashboard/src/@/api/rewind/get-year-in-review.ts new file mode 100644 index 00000000000..74905cd2bca --- /dev/null +++ b/apps/dashboard/src/@/api/rewind/get-year-in-review.ts @@ -0,0 +1,182 @@ +import "server-only"; +import { + getAggregateUserOpUsage, + getEOAAndInAppWalletConnections, + getRpcUsageByType, +} from "@/api/analytics"; + +type YearInReviewStats = { + totalRpcRequests: number; + totalWalletConnections: number; + totalMainnetSponsoredTransactions: number; + year: number; +}; + +/** + * Get year-in-review statistics for the current user across all their teams + * Hardcoded to 2025 (Jan 1, 2025 - Dec 31, 2025) + */ +export async function getYearInReview( + authToken: string, + teamIds: string[], +): Promise { + const year = 2025; + + if (!authToken || teamIds.length === 0) { + return { + totalRpcRequests: 0, + totalWalletConnections: 0, + totalMainnetSponsoredTransactions: 0, + year, + }; + } + + // Hardcoded to 2025: Jan 1, 2025 - Dec 31, 2025 + const yearStart = new Date(2025, 0, 1); + const yearEnd = new Date(2025, 11, 31, 23, 59, 59, 999); + + // Fetch all data in parallel across all teams + const [rpcRequests, walletConnections, sponsoredTxs] = await Promise.all([ + // Get total RPC requests across all teams + getTotalRpcRequests(teamIds, authToken, yearStart, yearEnd), + // Get total wallet connections across all teams + getTotalWalletConnections(teamIds, authToken, yearStart, yearEnd), + // Get total mainnet sponsored transactions across all teams + getTotalMainnetSponsoredTransactions( + teamIds, + authToken, + yearStart, + yearEnd, + ), + ]); + + return { + totalRpcRequests: rpcRequests, + totalWalletConnections: walletConnections, + totalMainnetSponsoredTransactions: sponsoredTxs, + year, + }; +} + +async function getTotalRpcRequests( + teamIds: string[], + authToken: string, + from: Date, + to: Date, +): Promise { + try { + // Aggregate RPC requests across all teams using the same API as analytics + const requests = await Promise.all( + teamIds.map(async (teamId) => { + try { + // Use getRpcUsageByType without projectId to get team-level data + // This matches the format used in the analytics pages + const usageData = await getRpcUsageByType( + { + teamId, + from, + to, + period: "all", + }, + authToken, + ); + + // Sum up all counts from the usage data + return usageData.reduce((sum, item) => sum + (item.count || 0), 0); + } catch (error) { + console.error(`Failed to fetch RPC usage for team ${teamId}:`, error); + return 0; + } + }), + ); + + return requests.reduce((sum, count) => sum + count, 0); + } catch (error) { + console.error("Failed to fetch RPC requests:", error); + return 0; + } +} + +async function getTotalWalletConnections( + teamIds: string[], + authToken: string, + from: Date, + to: Date, +): Promise { + try { + // Aggregate wallet connections across all teams + const connections = await Promise.all( + teamIds.map(async (teamId) => { + try { + const walletStats = await getEOAAndInAppWalletConnections( + { + teamId, + from, + to, + period: "all", + }, + authToken, + ); + + // Sum unique wallets connected (for "onboarded users" metric) + // Note: With period: "all", this should be a single aggregated stat, + // but we sum in case there are multiple stats (e.g., by wallet type) + return walletStats.reduce( + (sum, stat) => sum + (stat.uniqueWalletsConnected || 0), + 0, + ); + } catch (error) { + console.error( + `Failed to fetch wallet connections for team ${teamId}:`, + error, + ); + return 0; + } + }), + ); + + return connections.reduce((sum, count) => sum + count, 0); + } catch (error) { + console.error("Failed to fetch wallet connections:", error); + return 0; + } +} + +async function getTotalMainnetSponsoredTransactions( + teamIds: string[], + authToken: string, + from: Date, + to: Date, +): Promise { + try { + // Aggregate mainnet sponsored transactions across all teams + // getAggregateUserOpUsage filters out testnets automatically + const transactions = await Promise.all( + teamIds.map(async (teamId) => { + try { + const aggregateStats = await getAggregateUserOpUsage( + { + teamId, + from, + to, + }, + authToken, + ); + + return aggregateStats.successful || 0; + } catch (error) { + console.error( + `Failed to fetch mainnet sponsored transactions for team ${teamId}:`, + error, + ); + return 0; + } + }), + ); + + return transactions.reduce((sum, count) => sum + count, 0); + } catch (error) { + console.error("Failed to fetch mainnet sponsored transactions:", error); + return 0; + } +} diff --git a/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.tsx b/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.tsx index c46d610f74b..95beb4cb155 100644 --- a/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.tsx +++ b/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.tsx @@ -11,6 +11,7 @@ import { MobileBurgerMenuButton } from "../../components/MobileBurgerMenuButton" import { ThirdwebMiniLogo } from "../../components/ThirdwebMiniLogo"; import { TeamAndProjectSelectorPopoverButton } from "../../team/components/TeamHeader/TeamAndProjectSelectorPopoverButton"; import { TeamSelectorMobileMenuButton } from "../../team/components/TeamHeader/TeamSelectorMobileMenuButton"; +import { RewindBadge } from "./RewindBadge"; export type AccountHeaderCompProps = { className?: string; @@ -36,6 +37,7 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) { + diff --git a/apps/dashboard/src/app/(app)/account/components/RewindBadge.tsx b/apps/dashboard/src/app/(app)/account/components/RewindBadge.tsx new file mode 100644 index 00000000000..eee2d814b88 --- /dev/null +++ b/apps/dashboard/src/app/(app)/account/components/RewindBadge.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { ChevronsLeftIcon } from "lucide-react"; +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { RewindModal } from "../rewind/RewindModal"; + +export function RewindBadge({ className }: { className?: string }) { + const [open, setOpen] = useState(false); + const year = 2025; // Hardcoded to 2025 + + return ( + <> + + + + ); +} diff --git a/apps/dashboard/src/app/(app)/account/layout.tsx b/apps/dashboard/src/app/(app)/account/layout.tsx index a547308c520..e2af2ec135f 100644 --- a/apps/dashboard/src/app/(app)/account/layout.tsx +++ b/apps/dashboard/src/app/(app)/account/layout.tsx @@ -78,6 +78,10 @@ async function HeaderAndNav(props: { name: "Overview", path: "/account", }, + { + name: "Rewind", + path: "/account/rewind", + }, { name: "Settings", path: "/account/settings", diff --git a/apps/dashboard/src/app/(app)/account/page.tsx b/apps/dashboard/src/app/(app)/account/page.tsx index 9e7d7017eab..24ca3f2872b 100644 --- a/apps/dashboard/src/app/(app)/account/page.tsx +++ b/apps/dashboard/src/app/(app)/account/page.tsx @@ -6,6 +6,7 @@ import { getMemberByAccountId } from "@/api/team/team-members"; import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; import { loginRedirect } from "@/utils/redirects"; import { AccountTeamsUI } from "./overview/AccountTeamsUI"; +import { RewindModalClient } from "./rewind/RewindModalClient"; export default async function Page() { const [authToken, account, teams] = await Promise.all([ @@ -49,6 +50,8 @@ export default async function Page() {
+ + ); } diff --git a/apps/dashboard/src/app/(app)/account/rewind/RewindModal.tsx b/apps/dashboard/src/app/(app)/account/rewind/RewindModal.tsx new file mode 100644 index 00000000000..46203fda29d --- /dev/null +++ b/apps/dashboard/src/app/(app)/account/rewind/RewindModal.tsx @@ -0,0 +1,464 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@workspace/ui/components/button"; +import { + ChevronLeftIcon, + ChevronRightIcon, + SendIcon, + Share2Icon, + WalletIcon, + ZapIcon, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { reportRewindViewed } from "@/analytics/report"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { ThirdwebMiniLogo } from "../../components/ThirdwebMiniLogo"; + +type YearInReviewStats = { + totalRpcRequests: number; + totalWalletConnections: number; + totalMainnetSponsoredTransactions: number; + year: number; +}; + +async function fetchYearInReview(): Promise { + const res = await fetch(`/api/rewind`); + if (!res.ok) { + throw new Error("Failed to fetch year in review"); + } + return res.json(); +} + +function formatNumber(num: number): string { + if (num >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}M`; + } + if (num >= 1_000) { + return `${(num / 1_000).toFixed(1)}K`; + } + return num.toLocaleString(); +} + +const metrics = [ + { + key: "totalRpcRequests" as const, + label: "RPC Requests", + icon: ZapIcon, + color: "bg-blue-500", + message: (value: number) => `You sent ${formatNumber(value)} RPC requests`, + description: "Powering your dApps with lightning-fast infrastructure", + }, + { + key: "totalWalletConnections" as const, + label: "Wallet Connections", + icon: WalletIcon, + color: "bg-purple-500", + message: (value: number) => `You onboarded ${formatNumber(value)} users`, + description: "Helping users connect their wallets seamlessly", + }, + { + key: "totalMainnetSponsoredTransactions" as const, + label: "Sponsored Transactions", + icon: SendIcon, + color: "bg-green-500", + message: (value: number) => + `You sent ${formatNumber(value)} gasless transactions`, + description: "Removing barriers with sponsored transaction fees", + }, +]; + +const TOTAL_SLIDES = 5; // 1 intro slide + 3 metric slides + 1 share slide + +export function RewindModal({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const displayYear = 2025; + const [currentSlide, setCurrentSlide] = useState(0); + const { data, isLoading } = useQuery({ + queryKey: ["year-in-review", displayYear], + queryFn: () => fetchYearInReview(), + staleTime: 60 * 60 * 1000, + enabled: open, + }); + + const [_introPhase, setIntroPhase] = useState<"logo" | "wrapped">("logo"); + const [startAnimation, setStartAnimation] = useState(false); + + // Reset to first slide when modal opens + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (open) { + setCurrentSlide(0); + setIntroPhase("logo"); + setStartAnimation(false); + // Start animation after a brief moment (300ms) + const startTimer = setTimeout(() => { + setStartAnimation(true); + }, 300); + return () => clearTimeout(startTimer); + } + }, [open]); + + // Animate logo to wrapped position + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (open && currentSlide === 0 && startAnimation) { + // Phase 1: Show logo spinning and shrinking (5s), then mark as wrapped + const timer = setTimeout(() => { + setIntroPhase("wrapped"); + }, 5000); // Match the animation duration + + return () => clearTimeout(timer); + } else if (open && currentSlide === 0) { + // Reset to logo phase when modal opens + setIntroPhase("logo"); + } + }, [open, currentSlide, startAnimation]); + + // Report analytics when data loads + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (data && open) { + reportRewindViewed({ + year: displayYear, + totalRpcRequests: data.totalRpcRequests, + totalWalletConnections: data.totalWalletConnections, + totalMainnetSponsoredTransactions: + data.totalMainnetSponsoredTransactions, + }); + } + }, [data, open]); + + const handleNext = () => { + if (currentSlide < TOTAL_SLIDES - 1) { + setCurrentSlide(currentSlide + 1); + } + }; + + const handlePrev = () => { + if (currentSlide > 0) { + setCurrentSlide(currentSlide - 1); + } + }; + + const handleShare = () => { + if (!data) return; + const text = `I sent ${formatNumber(data.totalRpcRequests)} RPC requests, connected ${formatNumber(data.totalWalletConnections)} wallets, and sponsored ${formatNumber(data.totalMainnetSponsoredTransactions)} mainnet transactions on thirdweb this year! 🚀`; + + if (navigator.share) { + navigator + .share({ + title: `My ${displayYear} thirdweb Rewind`, + text, + url: window.location.href, + }) + .catch(() => { + navigator.clipboard.writeText(text); + }); + } else { + navigator.clipboard.writeText(text); + } + }; + + if (isLoading) { + return ( + + + + {displayYear} Year in Review - Loading + +
+
+
+

+ Loading your year in review... +

+
+
+ +
+ ); + } + + if (!data) { + return null; + } + + return ( + + + + {displayYear} Year in Review + + + {/* Header - only show on non-intro slides */} + {currentSlide !== 0 && ( +
+
+
+

+ {displayYear} wrapped +

+

+ Powered by thirdweb +

+
+ +
+
+ )} + + {/* Content */} +
+ {/* Intro Slide with Logo Animation */} +
+
+ {/* Close button for intro slide */} + + {/* Top: Full thirdweb Logo */} +
+ + + thirdweb + +
+ + {/* Center: Year and Wrapped */} +
+ {/* Year - show immediately */} +
+ {displayYear} +
+ + {/* Wrapped with animated logo as "W" */} +
+ {/* Logo: Starts at normal size, spins */} +
+ +
+ {/* "rapped" text appears immediately, fades in */} + + rapped + +
+
+ + {/* Bottom: View highlights button */} + +
+
+ + {/* Individual Metric Slides */} + {metrics.map((metric, index) => { + const metricSlideIndex = index + 1; // Metrics start at slide 1 + const Icon = metric.icon; + const value = data[metric.key]; + return ( +
+
+
+ +
+

+ {metric.message(value)} +

+

+ {metric.description} +

+
+
+ ); + })} + + {/* Share Slide */} +
+
+
+ +
+

+ Share Your Year +

+

+ Show off your {displayYear} achievements with your team and the + community! +

+ +
+
+
+ + {/* Footer with Navigation */} + {currentSlide > 0 && ( +
+
+ {/* Navigation Arrows */} +
+ + +
+ + {/* Slide Indicators */} +
+ {Array.from({ length: TOTAL_SLIDES }, (_, i) => i).map( + (slideIndex) => ( +
+ + {/* Slide Counter */} +
+ {currentSlide + 1} / {TOTAL_SLIDES} +
+
+
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/account/rewind/RewindModalClient.tsx b/apps/dashboard/src/app/(app)/account/rewind/RewindModalClient.tsx new file mode 100644 index 00000000000..e9c3acc7e50 --- /dev/null +++ b/apps/dashboard/src/app/(app)/account/rewind/RewindModalClient.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RewindModal } from "./RewindModal"; + +export function RewindModalClient() { + const [open, setOpen] = useState(false); + const displayYear = 2025; // Match the hardcoded year in RewindModal + const currentMonth = new Date().getMonth(); + const _currentDay = new Date().getDate(); + + // Show rewind modal in December or January + // Check if user has seen it for the display year + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + const hasSeenRewind = localStorage.getItem(`rewind-seen-${displayYear}`); + const shouldShowRewind = + (currentMonth === 11 || currentMonth === 0) && !hasSeenRewind; + + if (shouldShowRewind) { + // Small delay for better UX + const timer = setTimeout(() => { + setOpen(true); + }, 1000); + + return () => clearTimeout(timer); + } + }, [currentMonth]); + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + if (!newOpen) { + // Mark as seen for the display year + localStorage.setItem(`rewind-seen-${displayYear}`, "true"); + } + }; + + return ; +} diff --git a/apps/dashboard/src/app/(app)/account/rewind/page.tsx b/apps/dashboard/src/app/(app)/account/rewind/page.tsx new file mode 100644 index 00000000000..4215fb60731 --- /dev/null +++ b/apps/dashboard/src/app/(app)/account/rewind/page.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Button } from "@workspace/ui/components/button"; +import { SparklesIcon } from "lucide-react"; +import { useState } from "react"; +import { RewindModal } from "./RewindModal"; + +export default function RewindPage() { + const [open, setOpen] = useState(true); + const displayYear = 2025; + + return ( +
+
+
+ + + {displayYear} Year in Review + +
+

+ Your thirdweb Rewind +

+

+ A look back at your journey building on web3 this year +

+ +
+ + +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx index 3726f034de8..4c4e2b7bd24 100644 --- a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx +++ b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx @@ -9,6 +9,7 @@ import { NotificationsButton } from "@/components/notifications/notification-but import type { Account } from "@/hooks/useApi"; import { cn } from "@/lib/utils"; import { getValidTeamPlan } from "@/utils/getValidTeamPlan"; +import { RewindBadge } from "../../../account/components/RewindBadge"; import { SecondaryNav } from "../../../components/Header/SecondaryNav/SecondaryNav"; import { MobileBurgerMenuButton } from "../../../components/MobileBurgerMenuButton"; import { ThirdwebMiniLogo } from "../../../components/ThirdwebMiniLogo"; @@ -53,6 +54,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) { + diff --git a/apps/dashboard/src/app/api/rewind/route.ts b/apps/dashboard/src/app/api/rewind/route.ts new file mode 100644 index 00000000000..925dd21a9f8 --- /dev/null +++ b/apps/dashboard/src/app/api/rewind/route.ts @@ -0,0 +1,27 @@ +import "server-only"; +import { NextResponse } from "next/server"; +import { getAuthToken } from "@/api/auth-token"; +import { getYearInReview } from "@/api/rewind/get-year-in-review"; +import { getTeams } from "@/api/team/get-team"; + +export async function GET(request: Request) { + const authToken = await getAuthToken(); + if (!authToken) { + return NextResponse.redirect(new URL("/account/rewind", request.url)); + } + + try { + const teams = await getTeams(); + const teamIds = teams?.map((t) => t.id) || []; + + // Hardcoded to 2025 + const stats = await getYearInReview(authToken, teamIds); + return NextResponse.json(stats); + } catch (error) { + console.error("Failed to fetch year in review:", error); + return NextResponse.json( + { error: "Failed to fetch year in review" }, + { status: 500 }, + ); + } +}