mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Update
This commit is contained in:
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;
|
||||
@@ -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;
|
||||
@@ -2,3 +2,4 @@ export { GradientBackground } from './GradientBackground';
|
||||
export { GlassCard } from './GlassCard';
|
||||
export { GlassButton } from './GlassButton';
|
||||
export { GlassInput } from './GlassInput';
|
||||
export { ShimmerEffect } from './ShimmerEffect';
|
||||
Reference in new issue
Block a user