🌍 Add CountryBar component for displaying country information

Add a new CountryBar component that displays:
- Country flag emoji with scale animation
- Country name with slide-in animation
- Animated user count with tabular numbers
- Progress bar that fills based on user count ratio

The component follows existing patterns from StatsCounter and
MeetupCard, using Remotion's spring animations and interpolation
for smooth entrance effects and a subtle glow pulse.

Includes comprehensive test suite with 30 tests covering:
- Basic rendering and props
- Custom styling and colors
- Bar rendering and animation
- Real-world country data scenarios

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HolgerHatGarKeineNode
2026-01-24 13:01:30 +01:00
parent 55feaeeb21
commit 5475b9ee34
2 changed files with 443 additions and 0 deletions

View File

@@ -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(<CountryBar {...defaultProps} />);
expect(container).toBeInTheDocument();
});
it("displays the country name", () => {
const { container } = render(<CountryBar {...defaultProps} />);
const nameElement = container.querySelector(".font-semibold.text-white");
expect(nameElement).toHaveTextContent("Germany");
});
it("displays the flag emoji", () => {
const { container } = render(<CountryBar {...defaultProps} />);
expect(container.textContent).toContain("🇩🇪");
});
it("displays the user count by default", () => {
const { container } = render(<CountryBar {...defaultProps} />);
const countElement = container.querySelector(".tabular-nums");
expect(countElement).toBeInTheDocument();
expect(countElement).toHaveTextContent("458");
});
it("hides user count when showCount is false", () => {
const { container } = render(
<CountryBar {...defaultProps} showCount={false} />
);
const countElement = container.querySelector(".tabular-nums");
expect(countElement).not.toBeInTheDocument();
});
it("displays a custom country name", () => {
const { container } = render(
<CountryBar {...defaultProps} countryName="Austria" />
);
const nameElement = container.querySelector(".font-semibold.text-white");
expect(nameElement).toHaveTextContent("Austria");
});
it("displays a custom flag emoji", () => {
const { container } = render(
<CountryBar {...defaultProps} flagEmoji="🇦🇹" />
);
expect(container.textContent).toContain("🇦🇹");
});
it("displays a custom user count", () => {
const { container } = render(
<CountryBar {...defaultProps} userCount={59} />
);
const countElement = container.querySelector(".tabular-nums");
expect(countElement).toHaveTextContent("59");
});
it("applies custom width style", () => {
const { container } = render(
<CountryBar {...defaultProps} width={600} />
);
const card = container.firstChild as HTMLElement;
expect(card).toHaveStyle({ width: "600px" });
});
it("applies default width of 500px", () => {
const { container } = render(<CountryBar {...defaultProps} />);
const card = container.firstChild as HTMLElement;
expect(card).toHaveStyle({ width: "500px" });
});
it("applies default accent color to user count", () => {
const { container } = render(<CountryBar {...defaultProps} />);
const countElement = container.querySelector(".tabular-nums");
expect(countElement).toHaveStyle({ color: "#f7931a" });
});
it("applies custom accent color to user count", () => {
const { container } = render(
<CountryBar {...defaultProps} accentColor="#00ff00" />
);
const countElement = container.querySelector(".tabular-nums");
expect(countElement).toHaveStyle({ color: "#00ff00" });
});
it("has rounded corners styling", () => {
const { container } = render(<CountryBar {...defaultProps} />);
const card = container.firstChild as HTMLElement;
expect(card).toHaveClass("rounded-xl");
});
it("has proper card background styling", () => {
const { container } = render(<CountryBar {...defaultProps} />);
const card = container.firstChild as HTMLElement;
expect(card).toHaveClass("bg-zinc-900/90");
});
it("has backdrop blur styling", () => {
const { container } = render(<CountryBar {...defaultProps} />);
const card = container.firstChild as HTMLElement;
expect(card).toHaveClass("backdrop-blur-sm");
});
it("has border styling", () => {
const { container } = render(<CountryBar {...defaultProps} />);
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(<CountryBar {...defaultProps} />);
const barContainer = container.querySelector(".bg-zinc-800\\/80");
expect(barContainer).toBeInTheDocument();
});
it("renders the fill bar inside the container", () => {
const { container } = render(<CountryBar {...defaultProps} />);
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(<CountryBar {...defaultProps} />);
const nameElement = container.querySelector(".font-semibold.text-white");
expect(nameElement).toHaveClass("truncate");
});
it("uses tabular-nums class for consistent number width", () => {
const { container } = render(<CountryBar {...defaultProps} />);
const countElement = container.querySelector(".tabular-nums");
expect(countElement).toBeInTheDocument();
});
it("uses monospace font family for user count", () => {
const { container } = render(<CountryBar {...defaultProps} />);
const countElement = container.querySelector(".tabular-nums");
expect(countElement).toHaveStyle({ fontFamily: "Inconsolata, monospace" });
});
it("renders correctly with zero user count", () => {
const { container } = render(
<CountryBar {...defaultProps} userCount={0} />
);
const countElement = container.querySelector(".tabular-nums");
expect(countElement).toHaveTextContent("0");
});
it("renders correctly with large user count", () => {
const { container } = render(
<CountryBar {...defaultProps} userCount={10000} />
);
const countElement = container.querySelector(".tabular-nums");
expect(countElement).toHaveTextContent("10000");
});
it("renders bar container with rounded-full class", () => {
const { container } = render(<CountryBar {...defaultProps} />);
const barContainer = container.querySelector(".bg-zinc-800\\/80");
expect(barContainer).toHaveClass("rounded-full");
});
it("renders fill bar with rounded-full class", () => {
const { container } = render(<CountryBar {...defaultProps} />);
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(
<CountryBar {...defaultProps} userCount={229} maxCount={458} />
);
// Component should render without errors with maxCount
expect(container).toBeInTheDocument();
});
it("renders correctly with Switzerland data", () => {
const { container } = render(
<CountryBar
countryName="Switzerland"
flagEmoji="🇨🇭"
userCount={34}
/>
);
expect(container.textContent).toContain("Switzerland");
expect(container.textContent).toContain("🇨🇭");
expect(container.textContent).toContain("34");
});
it("renders correctly with Luxembourg data", () => {
const { container } = render(
<CountryBar
countryName="Luxembourg"
flagEmoji="🇱🇺"
userCount={8}
/>
);
expect(container.textContent).toContain("Luxembourg");
expect(container.textContent).toContain("🇱🇺");
expect(container.textContent).toContain("8");
});
it("renders correctly with Bulgaria data", () => {
const { container } = render(
<CountryBar
countryName="Bulgaria"
flagEmoji="🇧🇬"
userCount={7}
/>
);
expect(container.textContent).toContain("Bulgaria");
expect(container.textContent).toContain("🇧🇬");
expect(container.textContent).toContain("7");
});
it("renders correctly with Spain data", () => {
const { container } = render(
<CountryBar
countryName="Spain"
flagEmoji="🇪🇸"
userCount={3}
/>
);
expect(container.textContent).toContain("Spain");
expect(container.textContent).toContain("🇪🇸");
expect(container.textContent).toContain("3");
});
});

View File

@@ -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<CountryBarProps> = ({
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 (
<div
className="flex flex-col rounded-xl bg-zinc-900/90 backdrop-blur-sm border border-zinc-700/50"
style={{
width,
padding,
transform: `scale(${cardScale})`,
opacity: cardOpacity,
boxShadow: `0 0 ${20 * glowIntensity}px ${accentColor}20, 0 4px 15px rgba(0, 0, 0, 0.3)`,
}}
>
{/* Top row: Flag + Country name + User count */}
<div
className="flex items-center"
style={{
gap: padding * 0.75,
marginBottom: padding * 0.75,
}}
>
{/* Flag */}
<div
className="flex-shrink-0 flex items-center justify-center"
style={{
fontSize: flagSize,
transform: `scale(${flagScale})`,
lineHeight: 1,
}}
>
{flagEmoji}
</div>
{/* Country name */}
<div
className="font-semibold text-white truncate flex-1 min-w-0"
style={{
fontSize: width * 0.055,
opacity: nameOpacity,
transform: `translateX(${nameTranslateX}px)`,
lineHeight: 1.2,
}}
>
{countryName}
</div>
{/* User count */}
{showCount && (
<div
className="flex-shrink-0 font-bold tabular-nums"
style={{
fontSize: width * 0.055,
color: accentColor,
opacity: nameOpacity,
fontFamily: "Inconsolata, monospace",
textShadow: `0 0 ${10 * glowIntensity}px ${accentColor}60`,
}}
>
{displayCount}
</div>
)}
</div>
{/* Bar container */}
<div
className="relative rounded-full overflow-hidden bg-zinc-800/80"
style={{
height: barHeight,
}}
>
{/* Animated fill bar */}
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${barWidth * 100}%`,
background: `linear-gradient(90deg, ${accentColor}cc, ${accentColor})`,
boxShadow: `0 0 ${15 * glowIntensity}px ${accentColor}60`,
}}
/>
</div>
</div>
);
};