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, + }; +}