Files
Sauci/apps/mobile/src/components/BoutiquePackCard.tsx
T
jjbruntonandClaude Fable 5 6bc4e31719 chore(mobile): upgrade to Expo SDK 56
Bump expo, react, react-native, and all expo-* / react-native-* peers to
the SDK 56 line via expo install --fix, adding expo-splash-screen and
expo-system-ui for config plugins the SDK split out of core. Bump
@bacons/apple-targets, react-native-view-shot, and other native modules
to versions compatible with React Native 0.85 and the new architecture.

Remove @react-navigation/* direct dependencies and route their hooks
through expo-router, which is no longer compatible with react-navigation
as of SDK 56. Fix RN 0.85's removed StyleSheet.absoluteFillObject (use
absoluteFill) and react-native-view-shot 5.x's ViewShotRef type across
call sites. Downgrade eslint-plugin-react-hooks v7's new React Compiler
era rules to warnings pending a dedicated refactor, and repair Jest
fallout from the jest-expo/reanimated/worklets/expo-media-library bumps.

Regenerate the committed ios/ and android/ native projects with
`expo prebuild --clean` (required for @bacons/apple-targets 5.x) against
the production release channel, and document that requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:11:37 +01:00

331 lines
8.5 KiB
TypeScript

import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity, Platform } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
useAnimatedStyle,
withSpring,
useSharedValue,
FadeIn,
} from 'react-native-reanimated';
import { ShimmerEffect } from './ui/ShimmerEffect';
import { GlassToggle } from './ui/GlassToggle';
import { colors, gradients, spacing, radius, typography, blur, shadows, animations } from '../theme';
import type { QuestionPack } from '../types';
// Boutique gradient palette for pack cards
const BOUTIQUE_GRADIENTS: Array<readonly [string, string]> = [
gradients.boutiqueRose as [string, string],
gradients.boutiquePurple as [string, string],
gradients.boutiqueDusty as [string, string],
gradients.boutiqueAmethyst as [string, string],
gradients.boutiqueMidnight as [string, string],
gradients.boutiqueGold as [string, string],
];
interface BoutiquePackCardProps {
pack: QuestionPack;
index: number;
isEnabled: boolean;
isPremiumLocked: boolean;
onPress: () => void;
onToggle: () => void;
}
const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity);
export function BoutiquePackCard({
pack,
index,
isEnabled,
isPremiumLocked,
onPress,
onToggle,
}: BoutiquePackCardProps) {
const scale = useSharedValue(1);
const cardGradient = BOUTIQUE_GRADIENTS[index % BOUTIQUE_GRADIENTS.length];
const questionCount = pack.questions?.[0]?.count || 0;
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = () => {
scale.value = withSpring(0.97, animations.spring);
};
const handlePressOut = () => {
scale.value = withSpring(1, animations.spring);
};
const useBlur = Platform.OS === 'ios';
const cardContent = (
<View style={styles.cardInner}>
{/* Background gradient */}
<LinearGradient
colors={cardGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
{/* Flat overlay - removed glass */}
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(0,0,0,0.4)' }]} />
{/* Silk texture effect - removed for flatter look
<LinearGradient
colors={gradients.silkLight as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[StyleSheet.absoluteFill, { opacity: 0.5 }]}
/>
*/}
{/* Top highlight - removed
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.topHighlight}
/>
*/}
{/* Content */}
<View style={styles.content}>
{/* PRO Badge (top-right) */}
{pack.is_premium && (
<View style={[
styles.proBadge,
isPremiumLocked && styles.proBadgeLocked
]}>
<Ionicons
name={isPremiumLocked ? 'lock-closed' : 'star'}
size={10}
color={colors.text}
/>
<Text style={styles.proBadgeText}>PRO</Text>
</View>
)}
{/* Pack Icon */}
<View style={[
styles.iconContainer,
isEnabled && styles.iconContainerActive,
pack.is_premium && !isPremiumLocked && styles.iconContainerPremium,
]}>
<Text style={styles.packEmoji}>{pack.icon || '📦'}</Text>
</View>
{/* Pack Name */}
<Text
style={[
styles.packName,
isPremiumLocked && styles.packNameLocked,
]}
numberOfLines={2}
>
{pack.name}
</Text>
{/* Description */}
<Text
style={[
styles.packDescription,
isPremiumLocked && styles.packDescriptionLocked,
]}
numberOfLines={2}
>
{isPremiumLocked ? 'Unlock with Pro' : pack.description}
</Text>
{/* Footer */}
<View style={styles.footer}>
<View style={styles.questionBadge}>
<Text style={styles.questionCount}>{questionCount}</Text>
<Text style={styles.questionLabel}>questions</Text>
</View>
<GlassToggle
value={isEnabled}
onValueChange={onToggle}
disabled={isPremiumLocked}
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
/>
</View>
</View>
{/* Lock overlay for premium locked packs */}
{isPremiumLocked && (
<Animated.View
entering={FadeIn.duration(200)}
style={styles.lockOverlay}
>
<View style={styles.lockIconContainer}>
<Ionicons name="lock-closed" size={28} color={colors.premium.gold} />
</View>
</Animated.View>
)}
</View>
);
const wrappedCard = pack.is_premium && !isPremiumLocked ? (
<ShimmerEffect enabled={true} style={styles.shimmerWrapper}>
{cardContent}
</ShimmerEffect>
) : (
cardContent
);
return (
<AnimatedTouchableOpacity
style={[
styles.card,
animatedStyle,
pack.is_premium && !isPremiumLocked && styles.cardPremium,
isEnabled && styles.cardEnabled,
]}
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={0.9}
>
{wrappedCard}
</AnimatedTouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: radius.lg,
overflow: 'hidden',
...shadows.md,
},
cardPremium: {
borderWidth: 1,
borderColor: colors.premium.gold,
},
cardEnabled: {
borderWidth: 1,
borderColor: colors.primary,
},
shimmerWrapper: {
borderRadius: radius.lg,
},
cardInner: {
minHeight: 180,
borderRadius: radius.lg,
overflow: 'hidden',
},
glassOverlay: {
display: 'none',
},
glassOverlayFallback: {
display: 'none',
},
topHighlight: {
display: 'none',
},
content: {
flex: 1,
padding: spacing.md,
justifyContent: 'space-between',
},
proBadge: {
position: 'absolute',
top: spacing.sm,
right: spacing.sm,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.premium.gold,
paddingHorizontal: spacing.sm,
paddingVertical: 3,
borderRadius: radius.xs,
gap: 3,
zIndex: 10,
},
proBadgeLocked: {
backgroundColor: colors.background, // Flat bg
},
proBadgeText: {
...typography.caption2,
color: colors.text,
fontWeight: '700',
},
iconContainer: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.sm,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
iconContainerActive: {
backgroundColor: colors.primaryLight,
borderColor: colors.primary,
},
iconContainerPremium: {
backgroundColor: colors.premium.goldLight,
borderColor: colors.premium.gold,
},
packEmoji: {
fontSize: 28,
},
packName: {
...typography.headline,
color: colors.text,
marginBottom: spacing.xs,
},
packNameLocked: {
color: colors.textSecondary,
},
packDescription: {
...typography.caption1,
color: colors.textSecondary,
flex: 1,
},
packDescriptionLocked: {
color: colors.textTertiary,
fontStyle: 'italic',
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: spacing.sm,
},
questionBadge: {
flexDirection: 'row',
alignItems: 'baseline',
gap: 4,
},
questionCount: {
...typography.headline,
color: colors.text,
},
questionLabel: {
...typography.caption2,
color: colors.textTertiary,
},
lockOverlay: {
...StyleSheet.absoluteFill,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
lockIconContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.premium.gold,
},
});
export default BoutiquePackCard;