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:
133
src/shared/components/CollectionCard.tsx
Normal file
133
src/shared/components/CollectionCard.tsx
Normal 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],
|
||||
},
|
||||
})
|
||||
}
|
||||
200
src/shared/components/WorkoutCard.tsx
Normal file
200
src/shared/components/WorkoutCard.tsx
Normal 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],
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user