diff --git a/.claude/commands/push-all.md b/.claude/commands/push-all.md
new file mode 100644
index 0000000..bc7ae1b
--- /dev/null
+++ b/.claude/commands/push-all.md
@@ -0,0 +1,47 @@
+---
+description: Security check, commit with sensible message, and push
+allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Bash(git branch:*)
+---
+
+## Context
+
+- Current branch: !`git branch --show-current`
+- Git status: !`git status --short`
+- Staged changes: !`git diff --cached --stat`
+- Unstaged changes: !`git diff --stat`
+
+## Your Task
+
+Perform a secure commit and push with the following steps:
+
+### Step 1: Security Check
+Before committing, scan the staged changes for potential secrets or sensitive data:
+- API keys, tokens, or credentials (patterns like `sk-`, `api_key`, `secret`, `password`, `token`, `bearer`)
+- `.env` files or environment variable files with actual values
+- Private keys or certificates
+- Database connection strings with credentials
+- Hardcoded URLs with authentication parameters
+
+Run `git diff --cached` to inspect what will be committed. If you find ANY potential secrets:
+1. STOP immediately
+2. List the specific files and lines containing potential secrets
+3. Do NOT proceed with the commit
+4. Suggest how to fix (use environment variables, add to .gitignore, etc.)
+
+### Step 2: Stage All Changes
+If security check passes, stage all changes with `git add -A`.
+
+### Step 3: Generate Commit Message
+Analyze the changes and create a concise, meaningful commit message that:
+- Summarizes what changed (not how)
+- Uses imperative mood ("Add feature" not "Added feature")
+- Is 50 chars or less for the subject line
+- Includes the standard Claude Code footer
+
+Look at recent commits with `git log --oneline -5` to match the repository's commit style.
+
+### Step 4: Commit and Push
+1. Create the commit with the generated message
+2. Push to the remote repository
+
+If any step fails, report the error and stop.
diff --git a/apps/mobile/app/(app)/dares.tsx b/apps/mobile/app/(app)/dares.tsx
index e0fa202..1f12e13 100644
--- a/apps/mobile/app/(app)/dares.tsx
+++ b/apps/mobile/app/(app)/dares.tsx
@@ -1,12 +1,31 @@
-import { View, Text, StyleSheet, Platform } from "react-native";
+import { View, Text, StyleSheet, Platform, Pressable, ActivityIndicator } from "react-native";
import { Ionicons } from "@expo/vector-icons";
-import Animated, { FadeInDown, FadeIn } from "react-native-reanimated";
+import Animated, { FadeInDown, FadeIn, useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
import { GradientBackground } from "../../src/components/ui";
-import { colors, spacing, radius, typography } from "../../src/theme";
+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 (
@@ -61,6 +80,41 @@ export default function DaresScreen() {
{/* Bottom teaser */}
Are you ready to play?
+
+ {/* Interest button */}
+ {isAuthenticated && !isLoading && (
+
+
+ {isToggling ? (
+
+ ) : (
+ <>
+
+
+ {isInterested ? "You're on the list!" : "I'm interested"}
+
+ >
+ )}
+
+
+ )}
@@ -167,4 +221,28 @@ const styles = StyleSheet.create({
color: colors.textTertiary,
textAlign: 'center',
},
+ interestButtonContainer: {
+ marginTop: spacing.xl,
+ },
+ interestButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(212, 175, 55, 0.1)',
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.sm + 2,
+ borderRadius: radius.full,
+ borderWidth: 1,
+ borderColor: 'rgba(212, 175, 55, 0.2)',
+ gap: spacing.sm,
+ },
+ interestButtonActive: {
+ backgroundColor: 'rgba(212, 175, 55, 0.15)',
+ borderColor: 'rgba(212, 175, 55, 0.3)',
+ },
+ interestButtonText: {
+ ...typography.subhead,
+ fontWeight: '600',
+ color: ACCENT_COLOR,
+ },
});
diff --git a/apps/mobile/app/(app)/quiz.tsx b/apps/mobile/app/(app)/quiz.tsx
index 82cdad0..683d378 100644
--- a/apps/mobile/app/(app)/quiz.tsx
+++ b/apps/mobile/app/(app)/quiz.tsx
@@ -1,12 +1,31 @@
-import { View, Text, StyleSheet, Platform } from "react-native";
+import { View, Text, StyleSheet, Platform, Pressable, ActivityIndicator } from "react-native";
import { Ionicons } from "@expo/vector-icons";
-import Animated, { FadeInDown, FadeIn } from "react-native-reanimated";
+import Animated, { FadeInDown, FadeIn, useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
import { GradientBackground } from "../../src/components/ui";
-import { colors, spacing, radius, typography } from "../../src/theme";
+import { colors, spacing, radius, typography, animations } from "../../src/theme";
+import { useFeatureInterest } from "../../src/hooks/useFeatureInterest";
const ACCENT_COLOR = colors.premium.rose;
+const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
+
export default function QuizScreen() {
+ const { isInterested, isLoading, isToggling, toggleInterest, isAuthenticated } =
+ useFeatureInterest("quiz");
+
+ 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 (
@@ -61,6 +80,41 @@ export default function QuizScreen() {
{/* Bottom teaser */}
Something special is being crafted for you
+
+ {/* Interest button */}
+ {isAuthenticated && !isLoading && (
+
+
+ {isToggling ? (
+
+ ) : (
+ <>
+
+
+ {isInterested ? "You're on the list!" : "I'm interested"}
+
+ >
+ )}
+
+
+ )}
@@ -167,4 +221,28 @@ const styles = StyleSheet.create({
color: colors.textTertiary,
textAlign: 'center',
},
+ interestButtonContainer: {
+ marginTop: spacing.xl,
+ },
+ interestButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(232, 164, 174, 0.1)',
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.sm + 2,
+ borderRadius: radius.full,
+ borderWidth: 1,
+ borderColor: 'rgba(232, 164, 174, 0.2)',
+ gap: spacing.sm,
+ },
+ interestButtonActive: {
+ backgroundColor: 'rgba(232, 164, 174, 0.15)',
+ borderColor: 'rgba(232, 164, 174, 0.3)',
+ },
+ interestButtonText: {
+ ...typography.subhead,
+ fontWeight: '600',
+ color: ACCENT_COLOR,
+ },
});
diff --git a/apps/mobile/src/hooks/useFeatureInterest.ts b/apps/mobile/src/hooks/useFeatureInterest.ts
new file mode 100644
index 0000000..4c58709
--- /dev/null
+++ b/apps/mobile/src/hooks/useFeatureInterest.ts
@@ -0,0 +1,79 @@
+import { useState, useEffect, useCallback } from "react";
+import { supabase } from "../lib/supabase";
+import { useAuthStore } from "../store";
+
+export function useFeatureInterest(featureName: string) {
+ const [isInterested, setIsInterested] = useState(false);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isToggling, setIsToggling] = useState(false);
+ const user = useAuthStore((state) => state.user);
+
+ // Fetch current interest status
+ useEffect(() => {
+ async function fetchInterest() {
+ if (!user?.id) {
+ setIsLoading(false);
+ return;
+ }
+
+ const { data } = await supabase
+ .from("feature_interests")
+ .select("id")
+ .eq("user_id", user.id)
+ .eq("feature_name", featureName)
+ .maybeSingle();
+
+ setIsInterested(!!data);
+ setIsLoading(false);
+ }
+
+ fetchInterest();
+ }, [user?.id, featureName]);
+
+ const toggleInterest = useCallback(async () => {
+ if (!user?.id || isToggling) return;
+
+ setIsToggling(true);
+ const wasInterested = isInterested;
+
+ // Optimistic update
+ setIsInterested(!wasInterested);
+
+ try {
+ if (wasInterested) {
+ // Remove interest
+ const { error } = await supabase
+ .from("feature_interests")
+ .delete()
+ .eq("user_id", user.id)
+ .eq("feature_name", featureName);
+
+ if (error) throw error;
+ } else {
+ // Add interest
+ const { error } = await supabase
+ .from("feature_interests")
+ .insert({
+ user_id: user.id,
+ feature_name: featureName,
+ });
+
+ if (error) throw error;
+ }
+ } catch (error) {
+ console.error("Error toggling feature interest:", error);
+ // Revert on error
+ setIsInterested(wasInterested);
+ } finally {
+ setIsToggling(false);
+ }
+ }, [user?.id, featureName, isInterested, isToggling]);
+
+ return {
+ isInterested,
+ isLoading,
+ isToggling,
+ toggleInterest,
+ isAuthenticated: !!user?.id,
+ };
+}