diff --git a/videos/src/components/CountryBar.test.tsx b/videos/src/components/CountryBar.test.tsx new file mode 100644 index 0000000..ea70a84 --- /dev/null +++ b/videos/src/components/CountryBar.test.tsx @@ -0,0 +1,258 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { CountryBar } from "./CountryBar"; + +// Mock Remotion hooks +vi.mock("remotion", () => ({ + useCurrentFrame: vi.fn(() => 60), + useVideoConfig: vi.fn(() => ({ fps: 30, width: 1920, height: 1080 })), + interpolate: vi.fn((value, inputRange, outputRange) => { + const [inMin, inMax] = inputRange; + const [outMin, outMax] = outputRange; + const progress = Math.max(0, Math.min(1, (value - inMin) / (inMax - inMin))); + return outMin + progress * (outMax - outMin); + }), + spring: vi.fn(() => 1), +})); + +describe("CountryBar", () => { + const defaultProps = { + countryName: "Germany", + flagEmoji: "🇩🇪", + userCount: 458, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + it("renders without errors", () => { + const { container } = render(); + expect(container).toBeInTheDocument(); + }); + + it("displays the country name", () => { + const { container } = render(); + const nameElement = container.querySelector(".font-semibold.text-white"); + expect(nameElement).toHaveTextContent("Germany"); + }); + + it("displays the flag emoji", () => { + const { container } = render(); + expect(container.textContent).toContain("🇩🇪"); + }); + + it("displays the user count by default", () => { + const { container } = render(); + const countElement = container.querySelector(".tabular-nums"); + expect(countElement).toBeInTheDocument(); + expect(countElement).toHaveTextContent("458"); + }); + + it("hides user count when showCount is false", () => { + const { container } = render( + + ); + const countElement = container.querySelector(".tabular-nums"); + expect(countElement).not.toBeInTheDocument(); + }); + + it("displays a custom country name", () => { + const { container } = render( + + ); + const nameElement = container.querySelector(".font-semibold.text-white"); + expect(nameElement).toHaveTextContent("Austria"); + }); + + it("displays a custom flag emoji", () => { + const { container } = render( + + ); + expect(container.textContent).toContain("🇦🇹"); + }); + + it("displays a custom user count", () => { + const { container } = render( + + ); + const countElement = container.querySelector(".tabular-nums"); + expect(countElement).toHaveTextContent("59"); + }); + + it("applies custom width style", () => { + const { container } = render( + + ); + const card = container.firstChild as HTMLElement; + expect(card).toHaveStyle({ width: "600px" }); + }); + + it("applies default width of 500px", () => { + const { container } = render(); + const card = container.firstChild as HTMLElement; + expect(card).toHaveStyle({ width: "500px" }); + }); + + it("applies default accent color to user count", () => { + const { container } = render(); + const countElement = container.querySelector(".tabular-nums"); + expect(countElement).toHaveStyle({ color: "#f7931a" }); + }); + + it("applies custom accent color to user count", () => { + const { container } = render( + + ); + const countElement = container.querySelector(".tabular-nums"); + expect(countElement).toHaveStyle({ color: "#00ff00" }); + }); + + it("has rounded corners styling", () => { + const { container } = render(); + const card = container.firstChild as HTMLElement; + expect(card).toHaveClass("rounded-xl"); + }); + + it("has proper card background styling", () => { + const { container } = render(); + const card = container.firstChild as HTMLElement; + expect(card).toHaveClass("bg-zinc-900/90"); + }); + + it("has backdrop blur styling", () => { + const { container } = render(); + const card = container.firstChild as HTMLElement; + expect(card).toHaveClass("backdrop-blur-sm"); + }); + + it("has border styling", () => { + const { container } = render(); + const card = container.firstChild as HTMLElement; + expect(card).toHaveClass("border"); + expect(card).toHaveClass("border-zinc-700/50"); + }); + + it("renders the progress bar container", () => { + const { container } = render(); + const barContainer = container.querySelector(".bg-zinc-800\\/80"); + expect(barContainer).toBeInTheDocument(); + }); + + it("renders the fill bar inside the container", () => { + const { container } = render(); + const fillBar = container.querySelector(".absolute.inset-y-0.left-0"); + expect(fillBar).toBeInTheDocument(); + }); + + it("country name has truncate class for overflow handling", () => { + const { container } = render(); + const nameElement = container.querySelector(".font-semibold.text-white"); + expect(nameElement).toHaveClass("truncate"); + }); + + it("uses tabular-nums class for consistent number width", () => { + const { container } = render(); + const countElement = container.querySelector(".tabular-nums"); + expect(countElement).toBeInTheDocument(); + }); + + it("uses monospace font family for user count", () => { + const { container } = render(); + const countElement = container.querySelector(".tabular-nums"); + expect(countElement).toHaveStyle({ fontFamily: "Inconsolata, monospace" }); + }); + + it("renders correctly with zero user count", () => { + const { container } = render( + + ); + const countElement = container.querySelector(".tabular-nums"); + expect(countElement).toHaveTextContent("0"); + }); + + it("renders correctly with large user count", () => { + const { container } = render( + + ); + const countElement = container.querySelector(".tabular-nums"); + expect(countElement).toHaveTextContent("10000"); + }); + + it("renders bar container with rounded-full class", () => { + const { container } = render(); + const barContainer = container.querySelector(".bg-zinc-800\\/80"); + expect(barContainer).toHaveClass("rounded-full"); + }); + + it("renders fill bar with rounded-full class", () => { + const { container } = render(); + const fillBar = container.querySelector(".absolute.inset-y-0.left-0"); + expect(fillBar).toHaveClass("rounded-full"); + }); + + it("accepts maxCount prop for calculating bar width", () => { + const { container } = render( + + ); + // Component should render without errors with maxCount + expect(container).toBeInTheDocument(); + }); + + it("renders correctly with Switzerland data", () => { + const { container } = render( + + ); + expect(container.textContent).toContain("Switzerland"); + expect(container.textContent).toContain("🇨🇭"); + expect(container.textContent).toContain("34"); + }); + + it("renders correctly with Luxembourg data", () => { + const { container } = render( + + ); + expect(container.textContent).toContain("Luxembourg"); + expect(container.textContent).toContain("🇱🇺"); + expect(container.textContent).toContain("8"); + }); + + it("renders correctly with Bulgaria data", () => { + const { container } = render( + + ); + expect(container.textContent).toContain("Bulgaria"); + expect(container.textContent).toContain("🇧🇬"); + expect(container.textContent).toContain("7"); + }); + + it("renders correctly with Spain data", () => { + const { container } = render( + + ); + expect(container.textContent).toContain("Spain"); + expect(container.textContent).toContain("🇪🇸"); + expect(container.textContent).toContain("3"); + }); +}); diff --git a/videos/src/components/CountryBar.tsx b/videos/src/components/CountryBar.tsx new file mode 100644 index 0000000..8525a60 --- /dev/null +++ b/videos/src/components/CountryBar.tsx @@ -0,0 +1,185 @@ +import { + useCurrentFrame, + useVideoConfig, + interpolate, + spring, +} from "remotion"; + +export interface CountryBarProps { + /** Country name (e.g., "Germany") */ + countryName: string; + /** Country flag emoji (e.g., "🇩🇪") */ + flagEmoji: string; + /** Number of users in this country */ + userCount: number; + /** Maximum user count for calculating bar width (default: same as userCount for full bar) */ + maxCount?: number; + /** Total width of the component in pixels (default: 500) */ + width?: number; + /** Delay in frames before animation starts */ + delay?: number; + /** Custom accent color for the bar (default: #f7931a - Bitcoin orange) */ + accentColor?: string; + /** Whether to show the user count number (default: true) */ + showCount?: boolean; +} + +export const CountryBar: React.FC = ({ + countryName, + flagEmoji, + userCount, + maxCount, + width = 500, + delay = 0, + accentColor = "#f7931a", + showCount = true, +}) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + + const adjustedFrame = Math.max(0, frame - delay); + + // Calculate bar fill percentage + const effectiveMax = maxCount ?? userCount; + const fillPercentage = effectiveMax > 0 ? userCount / effectiveMax : 0; + + // Card entrance animation + const cardSpring = spring({ + frame: adjustedFrame, + fps, + config: { damping: 15, stiffness: 80 }, + }); + + const cardScale = interpolate(cardSpring, [0, 1], [0.8, 1]); + const cardOpacity = interpolate(cardSpring, [0, 1], [0, 1]); + + // Flag animation (slightly delayed) + const flagSpring = spring({ + frame: adjustedFrame - 5, + fps, + config: { damping: 12, stiffness: 100 }, + }); + + const flagScale = interpolate(flagSpring, [0, 1], [0, 1]); + + // Country name animation + const nameSpring = spring({ + frame: adjustedFrame - 8, + fps, + config: { damping: 15, stiffness: 90 }, + }); + + const nameOpacity = interpolate(nameSpring, [0, 1], [0, 1]); + const nameTranslateX = interpolate(nameSpring, [0, 1], [-20, 0]); + + // Bar fill animation (delayed further) + const barSpring = spring({ + frame: adjustedFrame - 15, + fps, + config: { damping: 20, stiffness: 60 }, + }); + + const barWidth = interpolate(barSpring, [0, 1], [0, fillPercentage]); + + // Count animation (animates the number up) + const countSpring = spring({ + frame: adjustedFrame - 20, + fps, + config: { damping: 20, stiffness: 80 }, + durationInFrames: 45, + }); + + const displayCount = Math.round(countSpring * userCount); + + // Subtle glow pulse + const glowIntensity = interpolate( + Math.sin(adjustedFrame * 0.08), + [-1, 1], + [0.3, 0.5] + ); + + const padding = width * 0.04; + const flagSize = width * 0.1; + const barHeight = width * 0.06; + + return ( +
+ {/* Top row: Flag + Country name + User count */} +
+ {/* Flag */} +
+ {flagEmoji} +
+ + {/* Country name */} +
+ {countryName} +
+ + {/* User count */} + {showCount && ( +
+ {displayCount} +
+ )} +
+ + {/* Bar container */} +
+ {/* Animated fill bar */} +
+
+
+ ); +};