This commit is contained in:
Jamie Brunton committed 2025-12-31 10:40:17 +00:00
1 parent 71667d6104
commit f46f0d59df
24 files changed
+2647 -516

No files matched your search

@@ -0,0 +1,346 @@
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity, Switch, 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 { 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}
/>
{/* Glass overlay */}
{useBlur ? (
<BlurView
intensity={blur.light}
tint="dark"
style={[StyleSheet.absoluteFill, styles.glassOverlay]}
/>
) : (
<View style={[StyleSheet.absoluteFill, styles.glassOverlayFallback]} />
)}
{/* Silk texture effect */}
<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 */}
<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>
<Switch
value={isEnabled}
onValueChange={onToggle}
disabled={isPremiumLocked}
trackColor={{
false: 'rgba(255, 255, 255, 0.15)',
true: pack.is_premium ? colors.premium.gold : colors.primary,
}}
thumbColor={colors.text}
ios_backgroundColor="rgba(255, 255, 255, 0.15)"
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,
...shadows.glow(colors.premium.goldGlow),
},
cardEnabled: {
borderWidth: 1,
borderColor: colors.primary,
},
shimmerWrapper: {
borderRadius: radius.lg,
},
cardInner: {
minHeight: 180,
borderRadius: radius.lg,
overflow: 'hidden',
},
glassOverlay: {
backgroundColor: 'rgba(22, 33, 62, 0.3)',
},
glassOverlayFallback: {
backgroundColor: 'rgba(22, 33, 62, 0.5)',
},
topHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 40,
},
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.glass.backgroundLight,
},
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.absoluteFillObject,
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;
+224
View File
@@ -0,0 +1,224 @@
import React from 'react';
import { StyleSheet, View, Text, ScrollView, 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,
} from 'react-native-reanimated';
import { colors, gradients, spacing, radius, typography, blur, animations } from '../theme';
import type { Category } from '../types';
export type FilterType = 'all' | 'enabled' | string;
interface CategoryTabsProps {
categories: Category[];
selectedFilter: FilterType;
onSelectFilter: (filter: FilterType) => void;
enabledCount?: number;
}
const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity);
function CategoryTab({
label,
icon,
isSelected,
onPress,
badge,
}: {
label: string;
icon?: string | null;
isSelected: boolean;
onPress: () => void;
badge?: number;
}) {
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = () => {
scale.value = withSpring(0.95, animations.spring);
};
const handlePressOut = () => {
scale.value = withSpring(1, animations.spring);
};
const useBlur = Platform.OS === 'ios';
const tabInnerContent = (
<>
{icon === '✓' ? (
<Ionicons name="checkmark" size={16} color={colors.text} style={{ marginRight: 2 }} />
) : icon ? (
<Text style={styles.tabIcon}>{icon}</Text>
) : null}
<Text style={isSelected ? styles.tabTextSelected : styles.tabText}>{label}</Text>
{badge !== undefined && badge > 0 && (
<View style={[styles.badge, isSelected && styles.badgeSelected]}>
<Text style={styles.badgeText}>{badge}</Text>
</View>
)}
</>
);
return (
<AnimatedTouchableOpacity
style={[styles.tabContainer, animatedStyle]}
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={0.8}
>
{isSelected ? (
<LinearGradient
colors={gradients.primary as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.tabGradient}
>
{tabInnerContent}
</LinearGradient>
) : useBlur ? (
<BlurView
intensity={blur.light}
tint="dark"
style={styles.tabBlur}
>
<View style={styles.tabContent}>
{tabInnerContent}
</View>
</BlurView>
) : (
<View style={styles.tabFallback}>
{tabInnerContent}
</View>
)}
</AnimatedTouchableOpacity>
);
}
export function CategoryTabs({
categories,
selectedFilter,
onSelectFilter,
enabledCount = 0,
}: CategoryTabsProps) {
return (
<View style={styles.container}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{/* All tab */}
<CategoryTab
label="All"
isSelected={selectedFilter === 'all'}
onPress={() => onSelectFilter('all')}
/>
{/* Enabled tab */}
<CategoryTab
label="Enabled"
icon="✓"
isSelected={selectedFilter === 'enabled'}
onPress={() => onSelectFilter('enabled')}
badge={enabledCount}
/>
{/* Category tabs */}
{categories.map((category) => (
<CategoryTab
key={category.id}
label={category.name}
icon={category.icon}
isSelected={selectedFilter === category.id}
onPress={() => onSelectFilter(category.id)}
/>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
paddingVertical: spacing.sm,
},
scrollContent: {
paddingHorizontal: spacing.lg,
gap: spacing.sm,
},
tabContainer: {
borderRadius: radius.full,
overflow: 'hidden',
},
tabGradient: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
},
tabBlur: {
borderRadius: radius.full,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.glass.border,
},
tabFallback: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.glass.backgroundLight,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.glass.border,
gap: spacing.xs,
},
tabContent: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: spacing.xs,
},
tabIcon: {
fontSize: 16,
},
tabText: {
...typography.subhead,
color: colors.text, // Changed from textSecondary for better accessibility
fontWeight: '500',
},
tabTextSelected: {
...typography.subhead,
color: colors.text,
fontWeight: '600',
},
badge: {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
marginLeft: 4,
},
badgeSelected: {
backgroundColor: 'rgba(255, 255, 255, 0.3)',
},
badgeText: {
...typography.caption2,
color: colors.text,
fontWeight: '600',
},
});
export default CategoryTabs;
@@ -0,0 +1,484 @@
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Pressable, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import { Ionicons } from "@expo/vector-icons";
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withSequence,
withTiming,
withDelay,
FadeIn,
FadeOut,
interpolate,
} from "react-native-reanimated";
import { colors, gradients, radius, shadows, blur, typography, spacing } from "../theme";
import { Events } from "../lib/analytics";
interface Props {
onComplete: () => void;
}
interface TutorialStep {
key: string;
title: string;
description: string;
icon: keyof typeof Ionicons.glyphMap;
gradient: [string, string];
}
const STEPS: TutorialStep[] = [
{
key: "match",
title: "You've Got a Match!",
description: "When both you and your partner swipe YES or MAYBE on the same question, it creates a match. This means you're both interested!",
icon: "heart",
gradient: gradients.primary as [string, string],
},
{
key: "chat",
title: "Start a Private Chat",
description: "Tap any match to open a private conversation. Discuss, plan, and explore your shared interests together.",
icon: "chatbubbles",
gradient: gradients.success as [string, string],
},
{
key: "photos",
title: "Share Pictures Privately",
description: "Send photos that only your partner can see. Your intimate moments stay between the two of you, safe and private.",
icon: "images",
gradient: gradients.premiumRose as [string, string],
},
];
export default function MatchesTutorial({ onComplete }: Props) {
const insets = useSafeAreaInsets();
const [currentStep, setCurrentStep] = useState(0);
const pulseScale = useSharedValue(1);
const iconBounce = useSharedValue(0);
const currentStepData = STEPS[currentStep];
// Track tutorial started on mount
useEffect(() => {
Events.tutorialStarted("matches");
Events.tutorialStepViewed("matches", 0, STEPS[0].key);
}, []);
// Track step changes
useEffect(() => {
if (currentStep > 0) {
Events.tutorialStepViewed("matches", currentStep, STEPS[currentStep].key);
}
}, [currentStep]);
// Start animations
pulseScale.value = withRepeat(
withSequence(
withTiming(1.15, { duration: 800 }),
withTiming(1, { duration: 800 })
),
-1,
true
);
iconBounce.value = withRepeat(
withSequence(
withDelay(200, withTiming(-8, { duration: 400 })),
withTiming(0, { duration: 400 })
),
-1,
false
);
const handleNext = () => {
if (currentStep < STEPS.length - 1) {
setCurrentStep(currentStep + 1);
} else {
Events.tutorialCompleted("matches");
onComplete();
}
};
const handleSkip = () => {
Events.tutorialSkipped("matches", currentStep);
onComplete();
};
const pulseAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: pulseScale.value }],
opacity: interpolate(pulseScale.value, [1, 1.15], [0.6, 0.2]),
}));
const iconAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: iconBounce.value }],
}));
return (
<Animated.View
entering={FadeIn.duration(300)}
exiting={FadeOut.duration(200)}
style={styles.container}
>
<BlurView intensity={blur.heavy} tint="dark" style={styles.blur}>
<View style={[styles.content, { paddingBottom: Math.max(40, insets.bottom + (Platform.OS === 'ios' ? 100 : 80)) }]}>
{/* Skip button */}
<Pressable style={styles.skipButton} onPress={handleSkip}>
<Text style={styles.skipText}>Skip</Text>
</Pressable>
{/* Central illustration area */}
<View style={styles.illustrationArea}>
<Animated.View
key={currentStep}
entering={FadeIn.duration(400)}
>
{/* Pulse ring behind icon */}
<View style={styles.iconWrapper}>
<Animated.View style={[styles.pulseRing, pulseAnimatedStyle]}>
<LinearGradient
colors={currentStepData.gradient}
style={styles.pulseGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
{/* Main icon container */}
<Animated.View style={iconAnimatedStyle}>
<LinearGradient
colors={currentStepData.gradient}
style={styles.iconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons
name={currentStepData.icon}
size={56}
color={colors.text}
/>
</LinearGradient>
</Animated.View>
</View>
{/* Match type badges - show on first step */}
{currentStep === 0 && (
<View style={styles.badgesContainer}>
<View style={styles.badgeRow}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.matchBadge}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Ionicons name="heart" size={16} color={colors.text} />
<Text style={styles.badgeText}>YES + YES</Text>
</LinearGradient>
<View style={styles.badgeConnector} />
<View style={[styles.matchBadge, styles.maybeBadge]}>
<Ionicons name="heart-half" size={16} color={colors.primary} />
<Text style={[styles.badgeText, styles.maybeBadgeText]}>YES + MAYBE</Text>
</View>
</View>
</View>
)}
{/* Chat demo - show on second step */}
{currentStep === 1 && (
<View style={styles.chatDemo}>
<View style={styles.chatBubbleLeft}>
<Text style={styles.chatBubbleText}>I'd love to try this!</Text>
</View>
<View style={styles.chatBubbleRight}>
<Text style={styles.chatBubbleText}>Me too! When?</Text>
</View>
</View>
)}
{/* Photos demo - show on third step */}
{currentStep === 2 && (
<View style={styles.photosDemo}>
<View style={styles.photoRow}>
<View style={styles.photoPlaceholder}>
<Ionicons name="image" size={24} color={colors.textSecondary} />
</View>
<View style={styles.photoPlaceholder}>
<Ionicons name="image" size={24} color={colors.textSecondary} />
</View>
</View>
<View style={styles.privacyBadge}>
<Ionicons name="lock-closed" size={14} color={colors.success} />
<Text style={styles.privacyBadgeText}>Private & Secure</Text>
</View>
</View>
)}
</Animated.View>
</View>
{/* Text content */}
<Animated.View
key={`text-${currentStep}`}
entering={FadeIn.delay(100).duration(300)}
style={styles.textContainer}
>
<Text style={styles.title}>{currentStepData.title}</Text>
<Text style={styles.description}>{currentStepData.description}</Text>
</Animated.View>
{/* Progress dots */}
<View style={styles.dotsContainer}>
{STEPS.map((_, index) => (
<View
key={index}
style={[
styles.dot,
index === currentStep && styles.dotActive,
index < currentStep && styles.dotCompleted,
]}
/>
))}
</View>
{/* Next button */}
<Pressable onPress={handleNext} style={({ pressed }) => [
styles.nextButton,
pressed && styles.nextButtonPressed
]}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.nextButtonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Text style={styles.nextButtonText}>
{currentStep < STEPS.length - 1 ? "Next" : "Got it!"}
</Text>
<Ionicons
name={currentStep < STEPS.length - 1 ? "arrow-forward" : "checkmark"}
size={20}
color={colors.text}
/>
</LinearGradient>
</Pressable>
</View>
</BlurView>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
zIndex: 1000,
},
blur: {
flex: 1,
backgroundColor: 'rgba(13, 13, 26, 0.85)',
},
content: {
flex: 1,
paddingHorizontal: spacing.lg,
paddingTop: 80,
alignItems: "center",
},
skipButton: {
position: "absolute",
top: 60,
right: spacing.lg,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
},
skipText: {
...typography.callout,
color: colors.textSecondary,
},
illustrationArea: {
flex: 1,
justifyContent: "center",
alignItems: "center",
width: "100%",
maxHeight: 350,
},
iconWrapper: {
alignItems: "center",
justifyContent: "center",
marginBottom: spacing.xl,
},
pulseRing: {
position: "absolute",
width: 160,
height: 160,
borderRadius: 80,
overflow: "hidden",
},
pulseGradient: {
width: "100%",
height: "100%",
},
iconContainer: {
width: 120,
height: 120,
borderRadius: 60,
justifyContent: "center",
alignItems: "center",
...shadows.xl,
},
badgesContainer: {
marginTop: spacing.lg,
},
badgeRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
matchBadge: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
},
maybeBadge: {
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
},
badgeText: {
...typography.caption1,
color: colors.text,
fontWeight: "700",
letterSpacing: 0.5,
},
maybeBadgeText: {
color: colors.primary,
},
badgeConnector: {
width: 20,
height: 2,
backgroundColor: colors.glass.border,
},
chatDemo: {
marginTop: spacing.lg,
gap: spacing.sm,
width: "100%",
maxWidth: 280,
},
chatBubbleLeft: {
alignSelf: "flex-start",
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.lg,
borderBottomLeftRadius: radius.xs,
},
chatBubbleRight: {
alignSelf: "flex-end",
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.lg,
borderBottomRightRadius: radius.xs,
},
chatBubbleText: {
...typography.callout,
color: colors.text,
},
photosDemo: {
marginTop: spacing.lg,
alignItems: "center",
gap: spacing.md,
},
photoRow: {
flexDirection: "row",
gap: spacing.sm,
},
photoPlaceholder: {
width: 80,
height: 80,
borderRadius: radius.md,
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
justifyContent: "center",
alignItems: "center",
},
privacyBadge: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.success,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
},
privacyBadgeText: {
...typography.caption1,
color: colors.success,
fontWeight: "600",
},
textContainer: {
alignItems: "center",
marginBottom: spacing.xl,
paddingHorizontal: spacing.md,
},
title: {
...typography.title1,
color: colors.text,
textAlign: "center",
marginBottom: spacing.sm,
},
description: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
lineHeight: 24,
},
dotsContainer: {
flexDirection: "row",
gap: spacing.sm,
marginBottom: spacing.xl,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.glass.border,
},
dotActive: {
backgroundColor: colors.primary,
width: 24,
},
dotCompleted: {
backgroundColor: colors.primary,
},
nextButton: {
width: "100%",
maxWidth: 280,
borderRadius: radius.lg,
overflow: "hidden",
},
nextButtonPressed: {
opacity: 0.9,
transform: [{ scale: 0.98 }],
},
nextButtonGradient: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: spacing.md,
gap: spacing.sm,
},
nextButtonText: {
...typography.headline,
color: colors.text,
},
});
@@ -19,6 +19,7 @@ import Animated, {
runOnJS,
} from "react-native-reanimated";
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme";
import { Events } from "../lib/analytics";
interface Props {
onComplete: () => void;
@@ -110,6 +111,19 @@ export default function SwipeTutorial({ onComplete }: Props) {
const currentStep_ = STEPS[currentStep];
const isSwipeStep = currentStep_.isSwipe;
// Track tutorial started on mount
useEffect(() => {
Events.tutorialStarted("swipe");
Events.tutorialStepViewed("swipe", 0, STEPS[0].key);
}, []);
// Track step changes
useEffect(() => {
if (currentStep > 0) {
Events.tutorialStepViewed("swipe", currentStep, STEPS[currentStep].key);
}
}, [currentStep]);
useEffect(() => {
// Reset animations for current step
cardTranslateX.value = 0;
@@ -217,11 +231,13 @@ export default function SwipeTutorial({ onComplete }: Props) {
if (currentStep < STEPS.length - 1) {
setCurrentStep(currentStep + 1);
} else {
Events.tutorialCompleted("swipe");
onComplete();
}
};
const handleSkip = () => {
Events.tutorialSkipped("swipe", currentStep);
onComplete();
};
@@ -0,0 +1,104 @@
import React, { useEffect } from 'react';
import { StyleSheet, View, ViewStyle } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
interpolate,
Easing,
} from 'react-native-reanimated';
import { colors } from '../../theme';
interface ShimmerEffectProps {
children: React.ReactNode;
style?: ViewStyle;
shimmerColor?: string;
duration?: number;
enabled?: boolean;
}
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
export function ShimmerEffect({
children,
style,
shimmerColor = colors.premium.gold,
duration = 2500,
enabled = true,
}: ShimmerEffectProps) {
const shimmerTranslate = useSharedValue(-1);
useEffect(() => {
if (enabled) {
shimmerTranslate.value = withRepeat(
withTiming(1, {
duration,
easing: Easing.inOut(Easing.ease),
}),
-1, // infinite repeat
false // don't reverse
);
} else {
shimmerTranslate.value = -1;
}
}, [enabled, duration]);
const shimmerStyle = useAnimatedStyle(() => {
const translateX = interpolate(
shimmerTranslate.value,
[-1, 1],
[-200, 200]
);
return {
transform: [
{ translateX },
{ rotate: '25deg' },
],
};
});
return (
<View style={[styles.container, style]}>
{children}
{enabled && (
<Animated.View style={[styles.shimmerContainer, shimmerStyle]}>
<LinearGradient
colors={[
'transparent',
`${shimmerColor}40`, // 25% opacity
`${shimmerColor}60`, // 37% opacity
`${shimmerColor}40`, // 25% opacity
'transparent',
]}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
style={styles.shimmerGradient}
/>
</Animated.View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
overflow: 'hidden',
},
shimmerContainer: {
position: 'absolute',
top: -50,
bottom: -50,
width: 100,
left: '50%',
marginLeft: -50,
},
shimmerGradient: {
flex: 1,
width: '100%',
},
});
export default ShimmerEffect;
+1
View File
@@ -2,3 +2,4 @@ export { GradientBackground } from './GradientBackground';
export { GlassCard } from './GlassCard';
export { GlassButton } from './GlassButton';
export { GlassInput } from './GlassInput';
export { ShimmerEffect } from './ShimmerEffect';