mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
feat(dares): ship the dares loop to mobile
Completes the half-built dares feature: send a dare, respond, report done, confirm, and get a push notification on every transition. Backend (apps/api): - New `dares` domain with couple-shared premium entitlement, a free weekly send allowance, and server-enforced actor/state rules on every transition. - `submitted` status so the recipient reports completion and the sender confirms, rather than the sender guessing. - Notifications ride the existing operations outbox via a trigger on `sent_dares`. Push copy is generic; store compliance forbids dare text in a preview and a test asserts no payload leaks it. - Worker expiry sweep now covers `pending` as well as `active`/`submitted`; an invitation nobody opened previously stayed open forever. Fixes two latent defects: - Deleting any dare that had been sent aborted with a check violation, because `dare_id` is ON DELETE SET NULL while `sent_dares_dare_or_custom_check` required it. Dare text is now snapshotted at send time, which also keeps a couple's history stable across catalogue edits. - The free send allowance was raceable: concurrent requests all read the same count and every one succeeded. The count and insert are now serialized on the sender's profile row. Also ports `content_status` review metadata to the API catalogue, which had no store-safety gating at all, and mirrors the review decision already enforced on the legacy catalogue. Mobile: dares screen (active/send/history), send sheet with duration presets, home tile badging incoming dares, paywall on both proactive and 402 paths. Verified: lint, typecheck, 379 unit tests, build, 71 integration tests against local PostgreSQL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a7470310ae
commit
ce0b188776
34 files changed
+2673
-242
No files matched your search
@@ -1,232 +1,5 @@
|
||||
import { View, Text, StyleSheet, Platform, Pressable, ActivityIndicator } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Animated, { FadeInDown, FadeIn, useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
|
||||
import { GradientBackground } from "../../src/components/ui";
|
||||
import { colors, spacing, radius, typography, animations } from "../../src/theme";
|
||||
import { useFeatureInterest } from "../../src/hooks/useFeatureInterest";
|
||||
|
||||
const ACCENT_COLOR = colors.premium.gold;
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
||||
|
||||
export default function DaresScreen() {
|
||||
const { isInterested, isLoading, isToggling, toggleInterest, isAuthenticated } =
|
||||
useFeatureInterest("dares");
|
||||
|
||||
const scale = useSharedValue(1);
|
||||
const animatedButtonStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
const handlePressIn = () => {
|
||||
scale.value = withSpring(0.96, animations.spring);
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
scale.value = withSpring(1, animations.spring);
|
||||
};
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<View style={styles.container}>
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(600).springify()}
|
||||
style={styles.content}
|
||||
>
|
||||
{/* Title section */}
|
||||
<Text style={styles.label}>ADVENTURE</Text>
|
||||
<Text style={styles.title}>Dares</Text>
|
||||
|
||||
{/* Decorative separator */}
|
||||
<View style={styles.separator}>
|
||||
<View style={styles.separatorLine} />
|
||||
<View style={styles.separatorDiamond} />
|
||||
<View style={styles.separatorLine} />
|
||||
</View>
|
||||
|
||||
{/* Coming soon badge */}
|
||||
<Animated.View
|
||||
entering={FadeIn.delay(300).duration(400)}
|
||||
style={styles.badge}
|
||||
>
|
||||
<Text style={styles.badgeText}>ARRIVING SOON</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Description */}
|
||||
<Text style={styles.description}>
|
||||
Try playful challenges together and create unforgettable moments.
|
||||
</Text>
|
||||
|
||||
{/* Feature hints */}
|
||||
<View style={styles.features}>
|
||||
<View style={styles.featureItem}>
|
||||
<Ionicons name="sparkles" size={16} color={ACCENT_COLOR} />
|
||||
<Text style={styles.featureText}>Provocative challenges</Text>
|
||||
</View>
|
||||
<View style={styles.featureItem}>
|
||||
<Ionicons name="thermometer-outline" size={16} color={ACCENT_COLOR} />
|
||||
<Text style={styles.featureText}>Multiple intensity levels</Text>
|
||||
</View>
|
||||
<View style={styles.featureItem}>
|
||||
<Ionicons name="people-outline" size={16} color={ACCENT_COLOR} />
|
||||
<Text style={styles.featureText}>Designed for two</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bottom teaser */}
|
||||
<Text style={styles.teaser}>Are you ready to play?</Text>
|
||||
|
||||
{/* Interest button - always reserve space to prevent layout shift */}
|
||||
<View style={styles.interestButtonContainer}>
|
||||
{isAuthenticated && !isLoading && (
|
||||
<Animated.View entering={FadeIn.delay(500).duration(400)}>
|
||||
<AnimatedPressable
|
||||
onPress={toggleInterest}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={isToggling}
|
||||
style={[
|
||||
styles.interestButton,
|
||||
isInterested && styles.interestButtonActive,
|
||||
animatedButtonStyle,
|
||||
]}
|
||||
>
|
||||
{isToggling ? (
|
||||
<ActivityIndicator size="small" color={ACCENT_COLOR} />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons
|
||||
name={isInterested ? "checkmark-circle" : "notifications-outline"}
|
||||
size={16}
|
||||
color={ACCENT_COLOR}
|
||||
/>
|
||||
<Text style={styles.interestButtonText}>
|
||||
{isInterested ? "You're on the list!" : "I'm interested"}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</AnimatedPressable>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: 60,
|
||||
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
|
||||
},
|
||||
content: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
label: {
|
||||
...typography.caption1,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 3,
|
||||
color: ACCENT_COLOR,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
title: {
|
||||
...typography.largeTitle,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
},
|
||||
separator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginVertical: spacing.lg,
|
||||
width: 140,
|
||||
},
|
||||
separatorLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.3)',
|
||||
},
|
||||
separatorDiamond: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
backgroundColor: ACCENT_COLOR,
|
||||
transform: [{ rotate: '45deg' }],
|
||||
marginHorizontal: spacing.md,
|
||||
opacity: 0.6,
|
||||
},
|
||||
badge: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
interestButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
interestButtonActive: {
|
||||
backgroundColor: colors.background,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
interestButtonText: {
|
||||
...typography.subhead,
|
||||
fontWeight: '600',
|
||||
color: ACCENT_COLOR,
|
||||
},
|
||||
badgeText: {
|
||||
...typography.caption2,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 2,
|
||||
color: ACCENT_COLOR,
|
||||
},
|
||||
description: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
features: {
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
featureItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
featureText: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
teaser: {
|
||||
...typography.footnote,
|
||||
fontStyle: 'italic',
|
||||
color: colors.textTertiary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
interestButtonContainer: {
|
||||
marginTop: spacing.xl,
|
||||
minHeight: 44,
|
||||
},
|
||||
});
|
||||
import { DaresScreen } from "../../src/features/dares";
|
||||
|
||||
export default function DaresRoute() {
|
||||
return <DaresScreen />;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { BlurView } from 'expo-blur';
|
||||
import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store';
|
||||
import { GradientBackground } from '../../src/components/ui';
|
||||
import { Paywall } from '../../src/components/paywall';
|
||||
import { CompactHeader, ContentRow, LiveDrawTile } from '../../src/components/discovery';
|
||||
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery';
|
||||
import { colors, spacing, typography, radius } from '../../src/theme';
|
||||
import type { QuestionPack, Category } from '../../src/types';
|
||||
|
||||
@@ -117,6 +117,9 @@ export default function DiscoveryScreen() {
|
||||
{/* Live Draw Activity Tile */}
|
||||
<LiveDrawTile delay={100} />
|
||||
|
||||
{/* Dares */}
|
||||
<DaresTile delay={150} />
|
||||
|
||||
{/* Content Rows by Category */}
|
||||
{packsByCategory.map((group, index) => (
|
||||
<ContentRow
|
||||
|
||||
Reference in new issue
Block a user