Update mobile app UI with premium design system

- Add design guidelines to CLAUDE.md
- Redesign navigation with boutique styling
- Add video support to chat with compression
- Update matches/dares/quiz screens with gold/rose accents
- Improve packs browsing with category tabs
- Add SwipeCardPremium component
- Update theme with premium color palette
- Add video cleanup edge function and migration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 4.5 committed 2026-01-01 20:26:33 +00:00
1 parent feb7657562
commit cac7a2a31d
31 files changed
+6638 -1827

No files matched your search

+171 -38
View File
@@ -121,6 +121,19 @@ export function PackTeaser({
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.content, contentStyle]}>
{/* Premium gradient background */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.98)', 'rgba(13, 13, 26, 1)']}
style={StyleSheet.absoluteFill}
/>
{/* Top silk highlight */}
<LinearGradient
colors={['rgba(212, 175, 55, 0.08)', 'transparent']}
style={styles.silkHighlight}
/>
{/* Premium border accent */}
<View style={styles.premiumBorderAccent} />
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
@@ -136,12 +149,35 @@ export function PackTeaser({
{/* Header */}
<View style={styles.header}>
<View style={styles.iconContainer}>
<Text style={styles.packEmoji}>{packIcon || "📦"}</Text>
{/* Premium label */}
<Text style={styles.label}>PREMIUM COLLECTION</Text>
{/* Icon with glow */}
<View style={styles.iconWrapper}>
<View style={styles.iconContainer}>
<LinearGradient
colors={['rgba(212, 175, 55, 0.15)', 'rgba(184, 134, 11, 0.1)']}
style={StyleSheet.absoluteFill}
/>
<Text style={styles.packEmoji}>{packIcon || "📦"}</Text>
</View>
{/* Crown badge */}
<View style={styles.crownBadge}>
<Ionicons name="star" size={12} color={colors.premium.gold} />
</View>
</View>
<Text style={styles.title}>{packName}</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<Text style={styles.subtitle}>
Preview a few questions from this pack
Preview a few questions from this exclusive pack
</Text>
</View>
@@ -149,7 +185,7 @@ export function PackTeaser({
<View style={styles.questionsContainer}>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} size="large" />
<ActivityIndicator color={colors.premium.gold} size="large" />
<Text style={styles.loadingText}>
Loading preview...
</Text>
@@ -159,12 +195,12 @@ export function PackTeaser({
<View key={question.id} style={styles.questionCard}>
<View style={styles.questionHeader}>
<View style={styles.intensityContainer}>
{[...Array(question.intensity)].map((_, i) => (
{[...Array(5)].map((_, i) => (
<Ionicons
key={i}
name="flame"
name={i < question.intensity ? "flame" : "flame-outline"}
size={12}
color={colors.primary}
color={i < question.intensity ? colors.premium.gold : 'rgba(255, 255, 255, 0.2)'}
/>
))}
</View>
@@ -188,40 +224,39 @@ export function PackTeaser({
{questions.length > 0 && (
<View style={styles.moreHint}>
<Ionicons
name="lock-closed"
name="diamond"
size={16}
color={colors.textTertiary}
color={colors.premium.gold}
/>
<Text style={styles.moreHintText}>
+ many more questions to unlock
+ many more exclusive questions to unlock
</Text>
</View>
)}
{/* Unlock Button */}
<View style={styles.actions}>
<TouchableOpacity
<Pressable
style={styles.unlockButton}
onPress={onUnlock}
activeOpacity={0.8}
>
<LinearGradient
colors={gradients.primary as [string, string]}
colors={[colors.premium.gold, colors.premium.goldDark]}
style={styles.unlockButtonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Ionicons
name="star"
size={20}
color={colors.text}
name="diamond"
size={18}
color={colors.background}
style={{ marginRight: spacing.sm }}
/>
<Text style={styles.unlockButtonText}>
Unlock with Pro
</Text>
</LinearGradient>
</TouchableOpacity>
</Pressable>
</View>
</ScrollView>
</Animated.View>
@@ -237,16 +272,35 @@ const styles = StyleSheet.create({
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.8)",
backgroundColor: "rgba(0, 0, 0, 0.85)",
},
content: {
backgroundColor: colors.backgroundLight,
backgroundColor: colors.background,
borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl,
maxHeight: "85%",
borderWidth: 1,
borderBottomWidth: 0,
borderColor: colors.glass.border,
borderColor: 'rgba(212, 175, 55, 0.2)',
overflow: 'hidden',
},
silkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 150,
borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl,
},
premiumBorderAccent: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 2,
backgroundColor: colors.premium.gold,
opacity: 0.4,
},
scrollContent: {
padding: spacing.lg,
@@ -260,36 +314,98 @@ const styles = StyleSheet.create({
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.glass.background,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
header: {
alignItems: "center",
marginBottom: spacing.xl,
},
iconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
label: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: colors.premium.gold,
textAlign: 'center',
marginBottom: spacing.md,
},
iconWrapper: {
position: 'relative',
marginBottom: spacing.md,
},
iconContainer: {
width: 80,
height: 80,
borderRadius: 40,
justifyContent: "center",
alignItems: "center",
overflow: 'hidden',
borderWidth: 2,
borderColor: colors.premium.gold,
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.6,
shadowRadius: 20,
elevation: 12,
},
crownBadge: {
position: 'absolute',
top: -4,
right: -4,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.premium.gold,
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 4,
elevation: 4,
},
packEmoji: {
fontSize: 36,
fontSize: 40,
},
title: {
...typography.title1,
color: colors.text,
marginBottom: spacing.xs,
textShadowColor: 'rgba(212, 175, 55, 0.3)',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 10,
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.md,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(212, 175, 55, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.premium.gold,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.8,
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
lineHeight: 24,
},
questionsContainer: {
marginBottom: spacing.lg,
@@ -305,10 +421,10 @@ const styles = StyleSheet.create({
marginTop: spacing.md,
},
questionCard: {
backgroundColor: colors.glass.background,
backgroundColor: 'rgba(22, 33, 62, 0.4)',
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.glass.border,
borderColor: 'rgba(212, 175, 55, 0.15)',
padding: spacing.md,
},
questionHeader: {
@@ -319,20 +435,23 @@ const styles = StyleSheet.create({
},
intensityContainer: {
flexDirection: "row",
backgroundColor: colors.primaryLight,
backgroundColor: 'rgba(212, 175, 55, 0.1)',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
gap: 1,
gap: 2,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)',
},
questionNumber: {
...typography.caption2,
color: colors.textTertiary,
color: colors.premium.champagne,
opacity: 0.7,
},
questionText: {
...typography.body,
color: colors.text,
lineHeight: 22,
lineHeight: 24,
},
emptyContainer: {
alignItems: "center",
@@ -347,11 +466,19 @@ const styles = StyleSheet.create({
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
gap: spacing.xs,
gap: spacing.sm,
backgroundColor: 'rgba(212, 175, 55, 0.08)',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.full,
alignSelf: 'center',
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)',
},
moreHintText: {
...typography.caption1,
color: colors.textTertiary,
color: colors.premium.champagne,
fontWeight: '500',
},
actions: {
marginBottom: spacing.md,
@@ -359,6 +486,11 @@ const styles = StyleSheet.create({
unlockButton: {
borderRadius: radius.lg,
overflow: "hidden",
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
unlockButtonGradient: {
flexDirection: "row",
@@ -368,7 +500,8 @@ const styles = StyleSheet.create({
},
unlockButtonText: {
...typography.headline,
color: colors.text,
color: colors.background,
fontWeight: '700',
},
});
+235 -59
View File
@@ -8,6 +8,7 @@ import {
ActivityIndicator,
ScrollView,
Platform,
Pressable,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
@@ -24,9 +25,9 @@ interface PaywallProps {
const FEATURES = [
{
icon: "heart" as const,
icon: "diamond" as const,
title: "Unlock All Packs",
description: "Access every question pack",
description: "Access every premium collection",
},
{
icon: "sparkles" as const,
@@ -34,7 +35,7 @@ const FEATURES = [
description: "New packs added regularly",
},
{
icon: "people" as const,
icon: "heart" as const,
title: "Share with Partner",
description: "Both of you get Pro access",
},
@@ -125,6 +126,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const savings = calculateSavings();
return (
<Modal
visible={visible}
@@ -134,6 +136,19 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
>
<View style={styles.overlay}>
<View style={styles.content}>
{/* Premium gradient background */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.98)', 'rgba(13, 13, 26, 1)']}
style={StyleSheet.absoluteFill}
/>
{/* Top silk highlight */}
<LinearGradient
colors={['rgba(212, 175, 55, 0.08)', 'transparent']}
style={styles.silkHighlight}
/>
{/* Premium border accent */}
<View style={styles.premiumBorderAccent} />
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
@@ -149,15 +164,29 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
{/* Header */}
<View style={styles.header}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="star" size={32} color={colors.text} />
</LinearGradient>
<Text style={styles.title}>Upgrade to Pro</Text>
{/* Premium label */}
<Text style={styles.label}>EXCLUSIVE ACCESS</Text>
{/* Icon with glow */}
<View style={styles.iconContainer}>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Ionicons name="diamond" size={32} color={colors.background} />
</View>
<Text style={styles.title}>Sauci Pro</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<Text style={styles.subtitle}>
Unlock all premium content for you and your partner
</Text>
@@ -165,13 +194,13 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
{/* Features */}
<View style={styles.features}>
{FEATURES.map((feature) => (
{FEATURES.map((feature, index) => (
<View key={feature.title} style={styles.featureRow}>
<View style={styles.featureIcon}>
<Ionicons
name={feature.icon}
size={20}
color={colors.primary}
size={18}
color={colors.premium.gold}
/>
</View>
<View style={styles.featureText}>
@@ -180,6 +209,11 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
{feature.description}
</Text>
</View>
<Ionicons
name="checkmark-circle"
size={20}
color={colors.premium.gold}
/>
</View>
))}
</View>
@@ -188,9 +222,9 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
<View style={styles.packages}>
{isLoadingOfferings ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} size="large" />
<ActivityIndicator color={colors.premium.gold} size="large" />
<Text style={styles.loadingText}>
Loading subscription options...
Loading options...
</Text>
</View>
) : offerings?.availablePackages ? (
@@ -200,17 +234,30 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const isAnnual = pkg.packageType === "ANNUAL";
return (
<TouchableOpacity
<Pressable
key={pkg.identifier}
onPress={() => setSelectedPackage(pkg)}
activeOpacity={0.7}
style={[
styles.packageCard,
isSelected && styles.packageCardSelected,
isAnnual && styles.packageCardAnnual,
]}
>
{isAnnual && (
<LinearGradient
colors={['rgba(212, 175, 55, 0.1)', 'rgba(184, 134, 11, 0.05)']}
style={StyleSheet.absoluteFill}
/>
)}
{isAnnual && savings && savings > 0 && (
<View style={styles.savingsBadge}>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
<Ionicons name="star" size={10} color={colors.background} />
<Text style={styles.savingsText}>
SAVE {savings}%
</Text>
@@ -226,15 +273,28 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
{isSelected && <View style={styles.radioInner} />}
</View>
<View style={styles.packageInfo}>
<Text style={styles.packageTitle}>
{isAnnual ? "Annual" : "Monthly"}
</Text>
<Text style={styles.packagePrice}>
<View style={styles.packageTitleRow}>
<Text style={[
styles.packageTitle,
isAnnual && styles.packageTitleAnnual
]}>
{isAnnual ? "Annual" : "Monthly"}
</Text>
{isAnnual && (
<View style={styles.bestValueBadge}>
<Text style={styles.bestValueText}>BEST VALUE</Text>
</View>
)}
</View>
<Text style={[
styles.packagePrice,
isAnnual && styles.packagePriceAnnual
]}>
{formatPrice(pkg)}
</Text>
</View>
</View>
</TouchableOpacity>
</Pressable>
);
})
) : (
@@ -266,7 +326,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
{/* Purchase Button */}
<View style={styles.actions}>
<TouchableOpacity
<Pressable
style={[
styles.purchaseButton,
(!selectedPackage || isPurchasing) &&
@@ -274,21 +334,23 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
]}
onPress={handlePurchase}
disabled={!selectedPackage || isPurchasing}
activeOpacity={0.8}
>
{isPurchasing ? (
<ActivityIndicator size="small" color={colors.text} />
<View style={styles.purchaseButtonLoading}>
<ActivityIndicator size="small" color={colors.background} />
</View>
) : (
<LinearGradient
colors={gradients.primary as [string, string]}
colors={[colors.premium.gold, colors.premium.goldDark]}
style={styles.purchaseButtonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Text style={styles.purchaseButtonText}>Subscribe Now</Text>
<Ionicons name="diamond" size={18} color={colors.background} style={{ marginRight: spacing.sm }} />
<Text style={styles.purchaseButtonText}>Unlock Pro</Text>
</LinearGradient>
)}
</TouchableOpacity>
</Pressable>
<TouchableOpacity
style={styles.restoreButton}
@@ -317,17 +379,36 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.8)",
backgroundColor: "rgba(0, 0, 0, 0.85)",
justifyContent: "flex-end",
},
content: {
backgroundColor: colors.backgroundLight,
backgroundColor: colors.background,
borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl,
maxHeight: "90%",
borderWidth: 1,
borderBottomWidth: 0,
borderColor: colors.glass.border,
borderColor: 'rgba(212, 175, 55, 0.2)',
overflow: 'hidden',
},
silkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 150,
borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl,
},
premiumBorderAccent: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 2,
backgroundColor: colors.premium.gold,
opacity: 0.4,
},
scrollContent: {
padding: spacing.lg,
@@ -341,49 +422,96 @@ const styles = StyleSheet.create({
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.glass.background,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
header: {
alignItems: "center",
marginBottom: spacing.xl,
},
label: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: colors.premium.gold,
textAlign: 'center',
marginBottom: spacing.md,
},
iconContainer: {
width: 72,
height: 72,
borderRadius: 36,
justifyContent: "center",
alignItems: "center",
overflow: 'hidden',
marginBottom: spacing.md,
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.6,
shadowRadius: 20,
elevation: 12,
},
title: {
...typography.title1,
color: colors.text,
marginBottom: spacing.xs,
textShadowColor: 'rgba(212, 175, 55, 0.3)',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 10,
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.md,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(212, 175, 55, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.premium.gold,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.8,
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
lineHeight: 24,
},
features: {
marginBottom: spacing.xl,
backgroundColor: 'rgba(212, 175, 55, 0.05)',
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.1)',
},
featureRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: spacing.md,
paddingVertical: spacing.sm,
},
featureIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.primaryLight,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(212, 175, 55, 0.1)',
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
},
featureText: {
flex: 1,
@@ -411,30 +539,38 @@ const styles = StyleSheet.create({
},
packageCard: {
position: "relative",
backgroundColor: colors.glass.background,
backgroundColor: 'rgba(22, 33, 62, 0.4)',
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.glass.border,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
padding: spacing.md,
overflow: "visible",
overflow: "hidden",
},
packageCardSelected: {
borderColor: colors.primary,
backgroundColor: colors.primaryLight,
borderColor: colors.premium.gold,
borderWidth: 2,
},
packageCardAnnual: {
borderColor: 'rgba(212, 175, 55, 0.3)',
},
savingsBadge: {
position: "absolute",
top: -10,
top: -1,
right: spacing.md,
backgroundColor: colors.primary,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.xs,
borderBottomLeftRadius: radius.sm,
borderBottomRightRadius: radius.sm,
overflow: 'hidden',
},
savingsText: {
...typography.caption2,
color: colors.text,
fontWeight: "700",
color: colors.background,
fontWeight: "800",
letterSpacing: 0.5,
},
packageContent: {
flexDirection: "row",
@@ -445,30 +581,55 @@ const styles = StyleSheet.create({
height: 24,
borderRadius: 12,
borderWidth: 2,
borderColor: colors.textSecondary,
borderColor: 'rgba(255, 255, 255, 0.3)',
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
},
radioOuterSelected: {
borderColor: colors.primary,
borderColor: colors.premium.gold,
},
radioInner: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: colors.primary,
backgroundColor: colors.premium.gold,
},
packageInfo: {
flex: 1,
},
packageTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
packageTitle: {
...typography.headline,
color: colors.text,
},
packageTitleAnnual: {
color: colors.premium.champagne,
},
bestValueBadge: {
backgroundColor: 'rgba(212, 175, 55, 0.2)',
paddingHorizontal: spacing.xs,
paddingVertical: 2,
borderRadius: radius.xs,
},
bestValueText: {
...typography.caption2,
color: colors.premium.gold,
fontWeight: '700',
letterSpacing: 0.5,
},
packagePrice: {
...typography.subhead,
color: colors.textSecondary,
marginTop: 2,
},
packagePriceAnnual: {
color: colors.premium.champagne,
opacity: 0.8,
},
errorContainer: {
alignItems: "center",
@@ -481,16 +642,16 @@ const styles = StyleSheet.create({
marginBottom: spacing.md,
},
retryButton: {
backgroundColor: colors.glass.background,
backgroundColor: 'rgba(212, 175, 55, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
borderColor: 'rgba(212, 175, 55, 0.2)',
},
retryButtonText: {
...typography.subhead,
color: colors.primary,
color: colors.premium.gold,
fontWeight: "600",
},
errorBanner: {
@@ -510,17 +671,31 @@ const styles = StyleSheet.create({
purchaseButton: {
borderRadius: radius.lg,
overflow: "hidden",
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
purchaseButtonDisabled: {
opacity: 0.5,
shadowOpacity: 0,
},
purchaseButtonGradient: {
purchaseButtonLoading: {
paddingVertical: spacing.md,
alignItems: "center",
backgroundColor: colors.premium.gold,
},
purchaseButtonGradient: {
flexDirection: 'row',
paddingVertical: spacing.md,
alignItems: "center",
justifyContent: 'center',
},
purchaseButtonText: {
...typography.headline,
color: colors.text,
color: colors.background,
fontWeight: '700',
},
restoreButton: {
alignItems: "center",
@@ -529,7 +704,8 @@ const styles = StyleSheet.create({
},
restoreText: {
...typography.subhead,
color: colors.primary,
color: colors.premium.gold,
opacity: 0.8,
},
legal: {
paddingHorizontal: spacing.md,
@@ -0,0 +1,513 @@
/**
* SwipeCardPremium - PoC of premium/boutique styling for the swipe experience
*
* Inspired by the "Coming Soon" Ann Summers aesthetic
*/
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, useWindowDimensions, Pressable } from "react-native";
import { Gesture, GestureDetector, TouchableOpacity } from "react-native-gesture-handler";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
runOnJS,
interpolate,
Extrapolate,
withTiming,
interpolateColor,
Easing,
FadeIn,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, featureColors, radius, shadows, blur, typography, spacing, animations } from "../theme";
// Use feature colors for consistency
const { accent: ACCENT } = featureColors.dares;
const { accent: ROSE } = featureColors.quiz;
// RGBA helpers for custom opacity levels (derived from theme colors)
// Gold: #D4AF37 = rgb(212, 175, 55)
// Rose: #E8A4AE = rgb(232, 164, 174)
const rgba = {
gold: (opacity: number) => `rgba(212, 175, 55, ${opacity})`,
rose: (opacity: number) => `rgba(232, 164, 174, ${opacity})`,
};
// Haptics helper
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
const MAX_CARD_WIDTH = 400;
const SWIPE_THRESHOLD = 100;
interface Props {
question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean };
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
}
export default function SwipeCardPremium({ question, onSwipe }: Props) {
const { width: screenWidth } = useWindowDimensions();
const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const rotation = useSharedValue(0);
const scale = useSharedValue(1);
const shadowExpand = useSharedValue(0);
const idleBreathing = useSharedValue(0);
const isGesturing = useSharedValue(false);
const context = useSharedValue({ x: 0, y: 0 });
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const gesture = Gesture.Pan()
.activeOffsetX([-15, 15])
.activeOffsetY([-15, 15])
.onStart(() => {
context.value = { x: translateX.value, y: translateY.value };
scale.value = withTiming(1.02, { duration: animations.timing.fast });
shadowExpand.value = withTiming(1, { duration: animations.timing.fast });
isGesturing.value = true;
})
.onUpdate((event) => {
translateX.value = event.translationX + context.value.x;
translateY.value = event.translationY + context.value.y;
rotation.value = interpolate(
translateX.value,
[-cardWidth / 2, cardWidth / 2],
[-8, 8], // Reduced rotation for elegance
Extrapolate.CLAMP
);
})
.onEnd(() => {
scale.value = withTiming(1, { duration: animations.timing.fast });
shadowExpand.value = withTiming(0, { duration: animations.timing.fast });
isGesturing.value = false;
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
const direction = translateX.value > 0 ? "right" : "left";
runOnJS(triggerHaptic)('medium');
translateX.value = withSpring(
direction === "right" ? screenWidth * 1.5 : -screenWidth * 1.5,
animations.springGentle
);
runOnJS(onSwipe)(direction);
} else if (translateY.value < -SWIPE_THRESHOLD) {
runOnJS(triggerHaptic)('medium');
translateY.value = withSpring(-screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("up");
} else if (translateY.value > SWIPE_THRESHOLD) {
runOnJS(triggerHaptic)('light');
translateY.value = withSpring(screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("down");
} else {
translateX.value = withSpring(0, animations.spring);
translateY.value = withSpring(0, animations.spring);
rotation.value = withSpring(0, animations.spring);
}
});
const animatedStyle = useAnimatedStyle(() => {
const idleOffset = isGesturing.value ? 0 : interpolate(idleBreathing.value, [0, 1], [0, -4]);
return {
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value + idleOffset },
{ rotate: `${rotation.value}deg` },
{ scale: scale.value },
],
};
});
const cardShadowStyle = useAnimatedStyle(() => ({
shadowOpacity: interpolate(shadowExpand.value, [0, 1], [0.2, 0.4]),
shadowRadius: interpolate(shadowExpand.value, [0, 1], [20, 32]),
shadowOffset: {
width: 0,
height: interpolate(shadowExpand.value, [0, 1], [10, 16]),
},
}));
const overlayStyle = (direction: "left" | "right" | "up" | "down") =>
useAnimatedStyle(() => {
let opacity = 0;
if (direction === "right") {
opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1]);
} else if (direction === "left") {
opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 1]);
} else if (direction === "up") {
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 1]);
} else {
opacity = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 1]);
}
return { opacity };
});
const handleButtonPress = async (direction: "left" | "right" | "up") => {
await triggerHaptic('light');
onSwipe(direction);
};
return (
<View style={[styles.cardWrapper, { width: cardWidth }]}>
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.cardOuter, animatedStyle, cardShadowStyle, { shadowColor: ACCENT }]}>
{/* Premium dark background */}
<View style={styles.cardBackground}>
{/* Subtle gradient overlay */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
style={StyleSheet.absoluteFill}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[rgba.gold(0.08), 'transparent']}
style={styles.silkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Swipe Overlays - Premium style */}
<Animated.View style={[styles.overlay, styles.overlayYes, overlayStyle("right")]} pointerEvents="none">
<View style={styles.overlayBadgePremium}>
<Text style={styles.overlayTextPremium}>YES</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, styles.overlayNo, overlayStyle("left")]} pointerEvents="none">
<View style={[styles.overlayBadgePremium, styles.overlayBadgeNo]}>
<Text style={[styles.overlayTextPremium, styles.overlayTextNo]}>NO</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, styles.overlayMaybe, overlayStyle("up")]} pointerEvents="none">
<View style={[styles.overlayBadgePremium, styles.overlayBadgeMaybe]}>
<Text style={[styles.overlayTextPremium, styles.overlayTextMaybe]}>MAYBE</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, styles.overlaySkip, overlayStyle("down")]} pointerEvents="none">
<View style={[styles.overlayBadgePremium, styles.overlayBadgeSkip]}>
<Text style={[styles.overlayTextPremium, styles.overlayTextSkip]}>SKIP</Text>
</View>
</Animated.View>
{/* Content */}
<View style={styles.content}>
{/* Intensity badge - more prominent */}
<View style={styles.intensityBadge}>
<Text style={styles.intensityLabel}>INTENSITY</Text>
<View style={styles.intensityRow}>
{[...Array(5)].map((_, i) => (
<Ionicons
key={i}
name={i < question.intensity ? "flame" : "flame-outline"}
size={18}
color={i < question.intensity ? ACCENT : 'rgba(255, 255, 255, 0.2)'}
/>
))}
</View>
</View>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
{/* Question text */}
<Text style={styles.questionText}>
{question.text}
</Text>
{/* Bottom separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
</View>
{/* Premium Action Buttons */}
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<PremiumButton
onPress={() => handleButtonPress("left")}
icon="close"
variant="no"
/>
<PremiumButton
onPress={() => handleButtonPress("up")}
icon="help"
variant="maybe"
small
/>
<PremiumButton
onPress={() => handleButtonPress("right")}
icon="heart"
variant="yes"
/>
</View>
</View>
</View>
{/* Premium border */}
<View style={styles.premiumBorder} pointerEvents="none" />
</Animated.View>
</GestureDetector>
</View>
);
}
function PremiumButton({
onPress,
icon,
variant,
small = false,
}: {
onPress: () => void;
icon: string;
variant: 'yes' | 'no' | 'maybe';
small?: boolean;
}) {
const buttonScale = useSharedValue(1);
const handlePressIn = () => {
buttonScale.value = withSpring(0.92, animations.spring);
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
}));
const getColors = () => {
switch (variant) {
case 'yes': return { bg: rgba.gold(0.15), border: rgba.gold(0.3), icon: ACCENT };
case 'no': return { bg: rgba.rose(0.15), border: rgba.rose(0.3), icon: ROSE };
case 'maybe': return { bg: 'rgba(255, 255, 255, 0.08)', border: 'rgba(255, 255, 255, 0.15)', icon: colors.textSecondary };
}
};
const c = getColors();
return (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
>
<Animated.View style={[
styles.premiumButton,
small && styles.premiumButtonSmall,
{ backgroundColor: c.bg, borderColor: c.border },
buttonAnimatedStyle,
]}>
<Ionicons
name={icon as any}
size={small ? 18 : 24}
color={c.icon}
/>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
position: "absolute",
maxWidth: MAX_CARD_WIDTH,
height: 500,
},
cardOuter: {
width: "100%",
height: "100%",
borderRadius: radius.xl,
overflow: "hidden",
},
cardBackground: {
flex: 1,
backgroundColor: '#0d0d1a',
borderRadius: radius.xl,
overflow: "hidden",
},
silkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 120,
},
premiumBorder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.xl,
},
intensityBadge: {
alignItems: 'center',
backgroundColor: 'rgba(212, 175, 55, 0.08)', // gold @ 0.08
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)', // gold @ 0.15
marginBottom: spacing.md,
},
intensityLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: 'rgba(212, 175, 55, 0.7)', // gold @ 0.7
marginBottom: spacing.xs,
},
intensityRow: {
flexDirection: 'row',
gap: 4,
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.lg,
width: 120,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: ACCENT,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.5,
},
questionText: {
...typography.title2,
color: colors.text,
textAlign: "center",
lineHeight: 32,
paddingHorizontal: spacing.sm,
},
footer: {
paddingBottom: spacing.xl,
paddingHorizontal: spacing.lg,
},
buttonContainer: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: spacing.lg,
},
premiumButton: {
width: 60,
height: 60,
borderRadius: 30,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
},
premiumButtonSmall: {
width: 48,
height: 48,
borderRadius: 24,
},
// Overlays
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderRadius: radius.xl,
},
overlayYes: {
backgroundColor: 'rgba(212, 175, 55, 0.15)', // gold @ 0.15
},
overlayNo: {
backgroundColor: 'rgba(232, 164, 174, 0.15)', // rose @ 0.15
},
overlayMaybe: {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
overlaySkip: {
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
overlayBadgePremium: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.full,
backgroundColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.4)', // gold @ 0.4
transform: [{ rotate: "-12deg" }],
},
overlayBadgeNo: {
backgroundColor: 'rgba(232, 164, 174, 0.2)', // rose @ 0.2
borderColor: 'rgba(232, 164, 174, 0.4)', // rose @ 0.4
},
overlayBadgeMaybe: {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255, 255, 255, 0.2)',
},
overlayBadgeSkip: {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255, 255, 255, 0.15)',
},
overlayTextPremium: {
fontSize: 28,
fontWeight: "600",
letterSpacing: 4,
color: ACCENT,
},
overlayTextNo: {
color: ROSE,
},
overlayTextMaybe: {
color: colors.textSecondary,
},
overlayTextSkip: {
color: colors.textTertiary,
fontSize: 24,
},
});