Skip to content

Commit c63e89e

Browse files
feat: trading card component
1 parent 6fdee09 commit c63e89e

File tree

6 files changed

+730
-0
lines changed

6 files changed

+730
-0
lines changed
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import * as React from "react";
2+
import { cn } from "../../utils/cn";
3+
4+
export interface TradingCardProps extends React.HTMLAttributes<HTMLDivElement> {
5+
/**
6+
* The image URL to display in the card
7+
*/
8+
imageUrl: string;
9+
10+
/**
11+
* The title of the card
12+
*/
13+
title: string;
14+
15+
/**
16+
* The subtitle or type of the card
17+
*/
18+
subtitle?: string;
19+
20+
/**
21+
* The main attribute value (like HP in Pokémon cards)
22+
*/
23+
attributeValue?: string | number;
24+
25+
/**
26+
* The attribute label (like "HP")
27+
*/
28+
attributeLabel?: string;
29+
30+
/**
31+
* The main content or description
32+
*/
33+
description?: string;
34+
35+
/**
36+
* Optional footer text
37+
*/
38+
footer?: string;
39+
40+
/**
41+
* Card border color
42+
*/
43+
borderColor?: string;
44+
45+
/**
46+
* Card background color
47+
*/
48+
backgroundColor?: string;
49+
50+
/**
51+
* Whether to enable 3D perspective effect
52+
*/
53+
enable3D?: boolean;
54+
55+
/**
56+
* Maximum rotation angle in degrees
57+
*/
58+
maxRotation?: number;
59+
60+
/**
61+
* Glare effect intensity (0-1)
62+
*/
63+
glareIntensity?: number;
64+
}
65+
66+
export const TradingCard = React.forwardRef<HTMLDivElement, TradingCardProps>(
67+
({
68+
className,
69+
imageUrl,
70+
title,
71+
subtitle,
72+
attributeValue,
73+
attributeLabel,
74+
description,
75+
footer,
76+
borderColor = "#4299e1", // Default blue color
77+
backgroundColor = "#ebf8ff", // Light blue background
78+
enable3D = true,
79+
maxRotation = 10,
80+
glareIntensity = 0.25,
81+
children,
82+
...props
83+
}, ref) => {
84+
const cardRef = React.useRef<HTMLDivElement>(null);
85+
const glareRef = React.useRef<HTMLDivElement>(null);
86+
87+
// State to track if the card is being touched/clicked
88+
const [isActive, setIsActive] = React.useState(false);
89+
90+
// Handle mouse/touch movement for 3D effect
91+
const handleMovement = React.useCallback(
92+
(e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent) => {
93+
if (!enable3D || !cardRef.current) return;
94+
95+
// Get card dimensions and position
96+
const card = cardRef.current;
97+
const rect = card.getBoundingClientRect();
98+
const cardWidth = rect.width;
99+
const cardHeight = rect.height;
100+
const cardCenterX = rect.left + cardWidth / 2;
101+
const cardCenterY = rect.top + cardHeight / 2;
102+
103+
// Get cursor/touch position
104+
let clientX: number, clientY: number;
105+
106+
if ('touches' in e) {
107+
// Touch event
108+
clientX = e.touches[0].clientX;
109+
clientY = e.touches[0].clientY;
110+
} else {
111+
// Mouse event
112+
clientX = (e as MouseEvent).clientX;
113+
clientY = (e as MouseEvent).clientY;
114+
}
115+
116+
// Calculate rotation based on cursor position relative to card center
117+
const rotateY = ((clientX - cardCenterX) / (cardWidth / 2)) * maxRotation;
118+
const rotateX = -((clientY - cardCenterY) / (cardHeight / 2)) * maxRotation;
119+
120+
// Apply rotation transform
121+
card.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
122+
123+
// Update glare effect if enabled
124+
if (glareRef.current && glareIntensity > 0) {
125+
const glareX = ((clientX - cardCenterX) / cardWidth) * 100 + 50;
126+
const glareY = ((clientY - cardCenterY) / cardHeight) * 100 + 50;
127+
128+
glareRef.current.style.background = `radial-gradient(
129+
circle at ${glareX}% ${glareY}%,
130+
rgba(255, 255, 255, ${glareIntensity}),
131+
transparent 50%
132+
)`;
133+
}
134+
},
135+
[enable3D, maxRotation, glareIntensity]
136+
);
137+
138+
// Reset card position when mouse leaves or touch ends
139+
const resetCardPosition = React.useCallback(() => {
140+
if (!enable3D || !cardRef.current) return;
141+
142+
setIsActive(false);
143+
144+
// Smoothly animate back to original position
145+
cardRef.current.style.transition = 'transform 0.5s ease-out';
146+
cardRef.current.style.transform = 'perspective(1000px) rotateX(0deg) rotateY(0deg)';
147+
148+
// Reset glare effect
149+
if (glareRef.current) {
150+
glareRef.current.style.background = 'transparent';
151+
}
152+
153+
// Remove transition after animation completes
154+
setTimeout(() => {
155+
if (cardRef.current) {
156+
cardRef.current.style.transition = '';
157+
}
158+
}, 500);
159+
}, [enable3D]);
160+
161+
// Set up event listeners
162+
React.useEffect(() => {
163+
if (!enable3D) return;
164+
165+
const card = cardRef.current;
166+
if (!card) return;
167+
168+
// Mouse events
169+
const handleMouseMove = (e: MouseEvent) => handleMovement(e);
170+
const handleMouseLeave = () => resetCardPosition();
171+
const handleMouseDown = () => setIsActive(true);
172+
const handleMouseUp = () => setIsActive(false);
173+
174+
// Touch events
175+
const handleTouchMove = (e: TouchEvent) => {
176+
e.preventDefault();
177+
handleMovement(e);
178+
};
179+
const handleTouchEnd = () => resetCardPosition();
180+
const handleTouchStart = () => setIsActive(true);
181+
182+
// Add event listeners
183+
card.addEventListener('mousemove', handleMouseMove);
184+
card.addEventListener('mouseleave', handleMouseLeave);
185+
card.addEventListener('mousedown', handleMouseDown);
186+
card.addEventListener('mouseup', handleMouseUp);
187+
card.addEventListener('touchmove', handleTouchMove, { passive: false });
188+
card.addEventListener('touchend', handleTouchEnd);
189+
card.addEventListener('touchstart', handleTouchStart);
190+
191+
// Clean up
192+
return () => {
193+
card.removeEventListener('mousemove', handleMouseMove);
194+
card.removeEventListener('mouseleave', handleMouseLeave);
195+
card.removeEventListener('mousedown', handleMouseDown);
196+
card.removeEventListener('mouseup', handleMouseUp);
197+
card.removeEventListener('touchmove', handleTouchMove);
198+
card.removeEventListener('touchend', handleTouchEnd);
199+
card.removeEventListener('touchstart', handleTouchStart);
200+
};
201+
}, [enable3D, handleMovement, resetCardPosition]);
202+
203+
return (
204+
<div
205+
ref={(node) => {
206+
if (typeof ref === 'function') {
207+
ref(node);
208+
} else if (ref) {
209+
ref.current = node;
210+
}
211+
(cardRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
212+
}}
213+
className={cn(
214+
"relative rounded-xl overflow-hidden",
215+
"w-72 h-[400px]", // Default size similar to trading cards
216+
"transition-shadow duration-300",
217+
"select-none", // Prevent text selection during drag
218+
isActive ? "cursor-grabbing" : "cursor-grab",
219+
className
220+
)}
221+
style={{
222+
backgroundColor: backgroundColor,
223+
boxShadow: `0 10px 30px -5px rgba(0, 0, 0, 0.3)`,
224+
border: `8px solid ${borderColor}`,
225+
transformStyle: 'preserve-3d',
226+
}}
227+
{...props}
228+
>
229+
{/* Glare effect overlay */}
230+
<div
231+
ref={glareRef}
232+
className="absolute inset-0 z-20 pointer-events-none"
233+
style={{
234+
borderRadius: 'inherit',
235+
}}
236+
/>
237+
238+
{/* Card header with title and attribute */}
239+
<div className="flex justify-between items-center p-3 border-b border-gray-200 bg-white/90">
240+
<div>
241+
<h3 className="font-bold text-lg">{title}</h3>
242+
{subtitle && <p className="text-xs text-gray-500">{subtitle}</p>}
243+
</div>
244+
{attributeValue && (
245+
<div className="flex items-center">
246+
<span className="font-bold text-xl">{attributeValue}</span>
247+
{attributeLabel && <span className="text-xs ml-1">{attributeLabel}</span>}
248+
</div>
249+
)}
250+
</div>
251+
252+
{/* Card image */}
253+
<div
254+
className="h-40 bg-cover bg-center"
255+
style={{
256+
backgroundImage: `url(${imageUrl})`,
257+
transform: 'translateZ(20px)', // 3D effect for the image
258+
transformStyle: 'preserve-3d',
259+
}}
260+
/>
261+
262+
{/* Card content */}
263+
<div className="p-4 bg-white/90">
264+
{description && (
265+
<p className="text-sm">{description}</p>
266+
)}
267+
{children}
268+
</div>
269+
270+
{/* Card footer */}
271+
{footer && (
272+
<div className="absolute bottom-0 left-0 right-0 p-2 text-xs text-center bg-gray-100 border-t border-gray-200">
273+
{footer}
274+
</div>
275+
)}
276+
</div>
277+
);
278+
}
279+
);
280+
281+
TradingCard.displayName = "TradingCard";

0 commit comments

Comments
 (0)