feat: add shared card components

- Add CollectionCard component for collection displays
- Add WorkoutCard component for workout previews
- Reusable across mobile and admin-web
This commit is contained in:
Millian Lamiaux
2026-03-14 20:44:19 +01:00
parent 8c8dbebd17
commit 001b376fc0
2 changed files with 333 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
/**
* CollectionCard - Premium collection card with glassmorphism
* Used in Home and Browse screens
*/
import { useMemo } from 'react'
import {
View,
StyleSheet,
Pressable,
ImageBackground,
Dimensions,
Text as RNText,
} from 'react-native'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { SPACING } from '@/src/shared/constants/spacing'
import { StyledText } from './StyledText'
import type { Collection } from '@/src/shared/types'
const { width: SCREEN_WIDTH } = Dimensions.get('window')
interface CollectionCardProps {
collection: Collection
onPress?: () => void
imageUrl?: string
}
export function CollectionCard({ collection, onPress, imageUrl }: CollectionCardProps) {
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
return (
<Pressable style={styles.container} onPress={onPress}>
{/* Background Image or Gradient */}
{imageUrl ? (
<ImageBackground
source={{ uri: imageUrl }}
style={StyleSheet.absoluteFill}
imageStyle={{ borderRadius: RADIUS.XL }}
resizeMode="cover"
>
<LinearGradient
colors={GRADIENTS.VIDEO_OVERLAY as [string, string]}
style={StyleSheet.absoluteFill}
/>
</ImageBackground>
) : (
<LinearGradient
colors={collection.gradient ?? [BRAND.PRIMARY, '#FF3B30']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[StyleSheet.absoluteFill, { borderRadius: RADIUS.XL }]}
/>
)}
{/* Glassmorphism Overlay */}
<View style={styles.overlay}>
<BlurView intensity={20} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
</View>
{/* Content */}
<View style={styles.content}>
<View style={styles.iconContainer}>
<RNText style={styles.icon}>{collection.icon}</RNText>
</View>
<StyledText
size={17}
weight="bold"
color="#FFFFFF"
numberOfLines={2}
style={styles.title}
>
{collection.title}
</StyledText>
<StyledText
size={13}
weight="medium"
color="rgba(255,255,255,0.7)"
numberOfLines={1}
>
{collection.workoutIds.length} workouts
</StyledText>
</View>
</Pressable>
)
}
function createStyles(colors: ThemeColors) {
const cardWidth = (SCREEN_WIDTH - SPACING[6] * 2 - SPACING[3]) / 2
return StyleSheet.create({
container: {
width: cardWidth,
aspectRatio: 1,
borderRadius: RADIUS.XL,
overflow: 'hidden',
...colors.shadow.md,
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.3)',
},
content: {
flex: 1,
padding: SPACING[4],
justifyContent: 'flex-end',
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 14,
backgroundColor: 'rgba(255,255,255,0.15)',
justifyContent: 'center',
alignItems: 'center',
marginBottom: SPACING[3],
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.2)',
},
icon: {
fontSize: 24,
},
title: {
marginBottom: SPACING[1],
},
})
}

View File

@@ -0,0 +1,200 @@
/**
* WorkoutCard - Premium workout card with glassmorphism
* Used in Home and Browse screens
*/
import { useMemo } from 'react'
import {
View,
StyleSheet,
Pressable,
ImageBackground,
Dimensions,
Text as RNText,
ViewStyle,
} from 'react-native'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { SPACING } from '@/src/shared/constants/spacing'
import { StyledText } from './StyledText'
import type { Workout, WorkoutCategory } from '@/src/shared/types'
const { width: SCREEN_WIDTH } = Dimensions.get('window')
export type WorkoutCardVariant = 'horizontal' | 'grid' | 'featured'
interface WorkoutCardProps {
workout: Workout
variant?: WorkoutCardVariant
onPress?: () => void
title?: string
metadata?: string
}
const CATEGORY_COLORS: Record<WorkoutCategory, string> = {
'full-body': BRAND.PRIMARY,
'core': '#5AC8FA',
'upper-body': '#BF5AF2',
'lower-body': '#30D158',
'cardio': '#FF9500',
}
const CATEGORY_LABELS: Record<WorkoutCategory, string> = {
'full-body': 'Full Body',
'core': 'Core',
'upper-body': 'Upper Body',
'lower-body': 'Lower Body',
'cardio': 'Cardio',
}
function getVariantDimensions(variant: WorkoutCardVariant): ViewStyle {
switch (variant) {
case 'featured':
return {
width: SCREEN_WIDTH - SPACING[6] * 2,
height: 320,
}
case 'horizontal':
return {
width: 200,
height: 280,
}
case 'grid':
default:
return {
flex: 1,
aspectRatio: 0.75,
}
}
}
export function WorkoutCard({
workout,
variant = 'horizontal',
onPress,
title,
metadata,
}: WorkoutCardProps) {
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const dimensions = useMemo(() => getVariantDimensions(variant), [variant])
const displayTitle = title ?? workout.title
const displayMetadata = metadata ?? `${workout.duration} min • ${workout.calories} cal`
const categoryColor = CATEGORY_COLORS[workout.category]
return (
<Pressable
style={[styles.container, dimensions]}
onPress={onPress}
>
{/* Background Image */}
<ImageBackground
source={workout.thumbnailUrl ? { uri: workout.thumbnailUrl } : undefined}
style={StyleSheet.absoluteFill}
imageStyle={{ borderRadius: RADIUS.XL }}
resizeMode="cover"
>
{/* Gradient Overlay */}
<LinearGradient
colors={GRADIENTS.VIDEO_OVERLAY as [string, string]}
style={StyleSheet.absoluteFill}
/>
</ImageBackground>
{/* Category Badge */}
<View style={[styles.categoryBadge, { backgroundColor: `${categoryColor}25`, borderColor: `${categoryColor}40` }]}>
<BlurView intensity={20} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<RNText style={[styles.categoryText, { color: categoryColor }]}>
{CATEGORY_LABELS[workout.category]}
</RNText>
</View>
{/* Play Button */}
<View style={styles.playButtonContainer}>
<View style={styles.playButton}>
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Ionicons name="play" size={24} color="#FFFFFF" style={{ marginLeft: 2 }} />
</View>
</View>
{/* Content at Bottom */}
<View style={styles.content}>
<StyledText
size={variant === 'featured' ? 22 : 17}
weight="bold"
color="#FFFFFF"
numberOfLines={2}
style={styles.title}
>
{displayTitle}
</StyledText>
<StyledText
size={13}
weight="medium"
color="rgba(255,255,255,0.7)"
numberOfLines={1}
>
{displayMetadata}
</StyledText>
</View>
</Pressable>
)
}
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
borderRadius: RADIUS.XL,
overflow: 'hidden',
...colors.shadow.lg,
},
categoryBadge: {
position: 'absolute',
top: SPACING[3],
left: SPACING[3],
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[1],
borderRadius: RADIUS.SM,
borderWidth: 1,
overflow: 'hidden',
},
categoryText: {
fontSize: 11,
fontWeight: '600',
},
playButtonContainer: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
},
playButton: {
width: 56,
height: 56,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.2)',
backgroundColor: 'rgba(255,255,255,0.1)',
},
content: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: SPACING[4],
},
title: {
marginBottom: SPACING[1],
},
})
}