From 55feaeeb2145cd28dcbd41db06fd5fe44ab0a408 Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode Date: Sat, 24 Jan 2026 12:58:07 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=83=8F=20Add=20MeetupCard=20component=20f?= =?UTF-8?q?or=20displaying=20meetup=20information?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Animated card component with logo, name, and location for showcasing Bitcoin meetups. Features spring-based entrance animations, location pin icon, and customizable styling with Bitcoin orange accent color. Co-Authored-By: Claude Opus 4.5 --- videos/src/components/MeetupCard.test.tsx | 134 +++++++++++++++++ videos/src/components/MeetupCard.tsx | 172 ++++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 videos/src/components/MeetupCard.test.tsx create mode 100644 videos/src/components/MeetupCard.tsx diff --git a/videos/src/components/MeetupCard.test.tsx b/videos/src/components/MeetupCard.test.tsx new file mode 100644 index 0000000..4cc59c7 --- /dev/null +++ b/videos/src/components/MeetupCard.test.tsx @@ -0,0 +1,134 @@ +/* eslint-disable @remotion/warn-native-media-tag */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { MeetupCard } from "./MeetupCard"; + +// Mock Remotion hooks +vi.mock("remotion", () => ({ + useCurrentFrame: vi.fn(() => 30), + 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), + Img: vi.fn(({ src, style }) => ( + meetup logo + )), +})); + +describe("MeetupCard", () => { + const defaultProps = { + logoSrc: "/test-logo.png", + name: "Bitcoin Stammtisch Berlin", + location: "Berlin, Germany", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + it("renders without errors", () => { + const { container } = render(); + expect(container).toBeInTheDocument(); + }); + + it("displays the meetup name", () => { + const { container } = render(); + const nameElement = container.querySelector(".font-bold.text-white"); + expect(nameElement).toHaveTextContent("Bitcoin Stammtisch Berlin"); + }); + + it("displays the location", () => { + const { container } = render(); + const locationElement = container.querySelector(".text-zinc-400"); + expect(locationElement).toHaveTextContent("Berlin, Germany"); + }); + + it("renders the logo image with correct src", () => { + const { container } = render(); + const logo = container.querySelector('[data-testid="meetup-logo"]'); + expect(logo).toHaveAttribute("src", "/test-logo.png"); + }); + + it("displays a custom meetup name", () => { + const { container } = render( + + ); + const nameElement = container.querySelector(".font-bold.text-white"); + expect(nameElement).toHaveTextContent("Einundzwanzig München"); + }); + + it("displays a custom location", () => { + const { container } = render( + + ); + const locationElement = container.querySelector(".text-zinc-400"); + expect(locationElement).toHaveTextContent("München, Bavaria"); + }); + + it("applies custom width style", () => { + const { container } = render( + + ); + const card = container.firstChild as HTMLElement; + expect(card).toHaveStyle({ width: "500px" }); + }); + + it("renders location pin SVG icon", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toBeInTheDocument(); + }); + + it("applies default accent color to location icon", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toHaveAttribute("stroke", "#f7931a"); + }); + + it("applies custom accent color to location icon", () => { + const { container } = render( + + ); + const svg = container.querySelector("svg"); + expect(svg).toHaveAttribute("stroke", "#00ff00"); + }); + + it("has rounded corners styling", () => { + const { container } = render(); + const card = container.firstChild as HTMLElement; + expect(card).toHaveClass("rounded-2xl"); + }); + + it("has proper card background styling", () => { + const { container } = render(); + const card = container.firstChild as HTMLElement; + expect(card).toHaveClass("bg-zinc-900/90"); + }); + + it("name has truncate class for overflow handling", () => { + const { container } = render(); + const nameElement = container.querySelector(".font-bold.text-white"); + expect(nameElement).toHaveClass("truncate"); + }); + + it("location text has truncate class for overflow handling", () => { + const { container } = render(); + const locationElement = container.querySelector(".text-zinc-400"); + expect(locationElement).toHaveClass("truncate"); + }); +}); diff --git a/videos/src/components/MeetupCard.tsx b/videos/src/components/MeetupCard.tsx new file mode 100644 index 0000000..f433f31 --- /dev/null +++ b/videos/src/components/MeetupCard.tsx @@ -0,0 +1,172 @@ +import { + useCurrentFrame, + useVideoConfig, + interpolate, + spring, + Img, +} from "remotion"; + +export type MeetupCardProps = { + /** URL or staticFile path for the meetup logo */ + logoSrc: string; + /** Name of the meetup */ + name: string; + /** Location of the meetup */ + location: string; + /** Delay in frames before animation starts */ + delay?: number; + /** Width of the card in pixels (default: 400) */ + width?: number; + /** Custom color for accent elements (default: #f7931a - Bitcoin orange) */ + accentColor?: string; +}; + +export const MeetupCard: React.FC = ({ + logoSrc, + name, + location, + delay = 0, + width = 400, + accentColor = "#f7931a", +}) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + + const adjustedFrame = Math.max(0, frame - delay); + + // 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]); + + // Logo animation (slightly delayed) + const logoSpring = spring({ + frame: adjustedFrame - 5, + fps, + config: { damping: 12, stiffness: 100 }, + }); + + const logoScale = interpolate(logoSpring, [0, 1], [0, 1]); + const logoRotation = interpolate(logoSpring, [0, 1], [0, 360]); + + // Text animations (staggered) + const nameSpring = spring({ + frame: adjustedFrame - 10, + fps, + config: { damping: 15, stiffness: 90 }, + }); + + const nameOpacity = interpolate(nameSpring, [0, 1], [0, 1]); + const nameTranslateY = interpolate(nameSpring, [0, 1], [20, 0]); + + const locationSpring = spring({ + frame: adjustedFrame - 15, + fps, + config: { damping: 15, stiffness: 90 }, + }); + + const locationOpacity = interpolate(locationSpring, [0, 1], [0, 1]); + const locationTranslateY = interpolate(locationSpring, [0, 1], [15, 0]); + + // Subtle glow pulse + const glowIntensity = interpolate( + Math.sin(adjustedFrame * 0.08), + [-1, 1], + [0.3, 0.5] + ); + + const logoSize = width * 0.25; + const padding = width * 0.06; + + return ( +
+ {/* Logo Container */} +
+ +
+ + {/* Text Content */} +
+ {/* Meetup Name */} +
+ {name} +
+ + {/* Location */} +
+ {/* Location pin icon */} + + + + + + {location} + +
+
+
+ ); +};