diff --git a/apps/admin/agents.md b/apps/admin/agents.md new file mode 100644 index 0000000..0ff8c0a --- /dev/null +++ b/apps/admin/agents.md @@ -0,0 +1,84 @@ +# Admin Dashboard - Agent Guidelines + +**STRICT RULES - DO NOT VIOLATE** + +## Architecture + +This is a React Admin dashboard using: +- **Build**: Vite +- **Routing**: react-router-dom with component-based routing in `App.tsx` +- **Styling**: Tailwind CSS +- **State**: React Context (`AuthContext`) +- **Backend**: Supabase client in `src/lib/supabase.ts` + +## Directory Structure + +``` +src/ + components/ # Reusable components + layout/ # AppLayout, Sidebar, etc. + ProtectedRoute.tsx # Route guard + contexts/ # React contexts (AuthContext) + hooks/ # Custom hooks + lib/ # Utilities (supabase) + pages/ # Page components + content/ # Categories, Packs, Questions + users/ # User management (super_admin only) + admins/ # Admin management (super_admin only) +``` + +## Critical Rules + +### Authentication & Authorization + +Two admin roles exist: +- `admin` - Content management only (categories, packs, questions) +- `super_admin` - Full access including user data, admins, audit logs + +Use `` for super_admin-only routes. + +### Route Structure + +```typescript +// Public +/login + +// Protected (all admins) +/ # Dashboard +/categories # Category management +/categories/:id/packs # Packs in category +/packs/:id/questions # Questions in pack + +// Protected (super_admin only) +/users # User list +/users/:id # User detail +/users/:id/matches/:id # Match chat view +/admins # Admin management +/audit-logs # Audit log viewer +``` + +### Supabase Queries + +- **Always use `.maybeSingle()` instead of `.single()`** when the row might not exist +- All admin operations are audited - check `audit_logs` table structure + +### UI Patterns + +- Use Tailwind CSS for styling +- Follow existing component patterns in `src/components/` +- Use `AppLayout` wrapper for all protected pages + +### Security + +- Never expose user passwords or sensitive data in admin views +- Audit log all administrative actions +- Verify admin role before performing operations + +## Forbidden Actions + +1. **DO NOT** expose user passwords or auth tokens +2. **DO NOT** allow content admins to access user data +3. **DO NOT** bypass role checks in protected routes +4. **DO NOT** perform destructive operations without confirmation +5. **DO NOT** use `.single()` for queries that might return 0 rows +6. **DO NOT** skip audit logging for administrative actions diff --git a/apps/admin/src/pages/users/UserDetailPage.tsx b/apps/admin/src/pages/users/UserDetailPage.tsx index d453309..8893932 100644 --- a/apps/admin/src/pages/users/UserDetailPage.tsx +++ b/apps/admin/src/pages/users/UserDetailPage.tsx @@ -34,7 +34,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift } from 'lucide-react'; +import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, HardDrive, ExternalLink } from 'lucide-react'; import { format } from 'date-fns'; interface Profile { @@ -77,6 +77,18 @@ interface Match { message_count?: number; } +interface MediaFile { + id: string; + name: string; + bucket_id: string; + created_at: string | null; + metadata: { + size: number; + mimetype: string; + }; + signedUrl?: string; +} + const answerIcons = { yes: , no: , @@ -89,12 +101,22 @@ const matchTypeLabels = { maybe_maybe: 'Both Maybe', }; +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + export function UserDetailPage() { const { userId } = useParams<{ userId: string }>(); const [profile, setProfile] = useState(null); const [partner, setPartner] = useState(null); const [responses, setResponses] = useState([]); const [matches, setMatches] = useState([]); + const [media, setMedia] = useState([]); + const [totalStorage, setTotalStorage] = useState(0); const [loading, setLoading] = useState(true); const [upgradeOpen, setUpgradeOpen] = useState(false); @@ -239,6 +261,31 @@ export function UserDetailPage() { })) as unknown as Match[] ); } + + // Fetch media files uploaded by this user + const { data: mediaData } = await supabase + .rpc('get_user_media_files', { user_id: userId }); + + if (mediaData && mediaData.length > 0) { + // Calculate total storage + const total = mediaData.reduce((sum: number, file: MediaFile) => + sum + (file.metadata?.size || 0), 0); + setTotalStorage(total); + + // Get signed URLs for each file + const mediaWithUrls = await Promise.all( + mediaData.map(async (file: MediaFile) => { + const { data: signedData } = await supabase.storage + .from('chat-media') + .createSignedUrl(file.name, 3600); // 1 hour expiry + return { + ...file, + signedUrl: signedData?.signedUrl, + }; + }) + ); + setMedia(mediaWithUrls); + } } catch (error) { console.error('Failed to load user data:', error); } finally { @@ -392,6 +439,9 @@ export function UserDetailPage() { Matches ({matches.length}) + + Media ({media.length}) + @@ -479,6 +529,79 @@ export function UserDetailPage() { )} + + + {media.length === 0 ? ( + + +

No media uploaded

+
+ ) : ( +
+ {/* Storage Summary */} + + +
+ +
+

Total Storage Used

+

{formatBytes(totalStorage)}

+
+
+ {media.length} file{media.length !== 1 ? 's' : ''} +
+
+
+
+ + {/* Media Grid */} +
+ {media.map((file) => ( + +
+ {file.signedUrl ? ( + {file.name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + ) : ( +
+ +
+ )} +
+ +
+
+ {formatBytes(file.metadata?.size || 0)} +
+ {file.signedUrl && ( + + + + )} +
+
+ {file.created_at + ? format(new Date(file.created_at), 'MMM d, yyyy') + : 'โ€”'} +
+
+
+ ))} +
+
+ )} +
); diff --git a/apps/admin/src/pages/users/UsersPage.tsx b/apps/admin/src/pages/users/UsersPage.tsx index 7a72736..db2376e 100644 --- a/apps/admin/src/pages/users/UsersPage.tsx +++ b/apps/admin/src/pages/users/UsersPage.tsx @@ -14,7 +14,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; -import { Search, Crown, Users, ChevronRight } from 'lucide-react'; +import { Search, Crown, Users, ChevronRight, HardDrive } from 'lucide-react'; import { format } from 'date-fns'; interface Profile { @@ -25,6 +25,15 @@ interface Profile { is_premium: boolean | null; couple_id: string | null; created_at: string | null; + storage_bytes?: number; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return 'โ€”'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } export function UsersPage() { @@ -37,14 +46,34 @@ export function UsersPage() { const fetchProfiles = async () => { setLoading(true); try { - const { data, error } = await supabase + // Fetch profiles + const { data: profilesData, error } = await supabase .from('profiles') .select('*') .order('created_at', { ascending: false }); if (error) throw error; - setProfiles(data || []); - setFilteredProfiles(data || []); + + // Fetch storage usage per user from storage.objects + const { data: storageData } = await supabase + .rpc('get_user_storage_usage'); + + // Create a map of user_id -> storage_bytes + const storageMap: Record = {}; + if (storageData) { + storageData.forEach((row: { owner: string; total_bytes: number }) => { + storageMap[row.owner] = row.total_bytes; + }); + } + + // Merge storage data with profiles + const profilesWithStorage = (profilesData || []).map(profile => ({ + ...profile, + storage_bytes: storageMap[profile.id] || 0, + })); + + setProfiles(profilesWithStorage); + setFilteredProfiles(profilesWithStorage); } catch (error) { console.error('Failed to load profiles:', error); } finally { @@ -114,6 +143,7 @@ export function UsersPage() { Email Status Couple + Storage Joined @@ -121,7 +151,7 @@ export function UsersPage() { {filteredProfiles.length === 0 ? ( - + {search ? 'No users found matching your search.' : 'No users yet.'} @@ -166,6 +196,16 @@ export function UsersPage() { โ€” )} + + {profile.storage_bytes && profile.storage_bytes > 0 ? ( +
+ + {formatBytes(profile.storage_bytes)} +
+ ) : ( + โ€” + )} +
{profile.created_at ? format(new Date(profile.created_at), 'MMM d, yyyy') diff --git a/apps/mobile/agents.md b/apps/mobile/agents.md new file mode 100644 index 0000000..8cb24dd --- /dev/null +++ b/apps/mobile/agents.md @@ -0,0 +1,102 @@ +# Mobile App - Agent Guidelines + +**STRICT RULES - DO NOT VIOLATE** + +## Architecture + +This is an Expo React Native app using: +- **Routing**: expo-router with file-based routing in `app/` directory +- **State**: Zustand stores in `src/store/index.ts` +- **Backend**: Supabase client in `src/lib/supabase.ts` +- **UI**: Glass-morphism themed components in `src/components/ui/` +- **Theme**: Centralized theme in `src/theme/index.ts` + +## Directory Structure + +``` +app/ # Expo Router routes + (auth)/ # Authentication screens + (app)/ # Protected app screens (tabs) +src/ + components/ # Reusable components + ui/ # Base UI components (GlassCard, GlassButton, etc.) + lib/ # Utilities (supabase, analytics, revenuecat) + store/ # Zustand stores (index.ts) + theme/ # Theme constants (colors, gradients, spacing, etc.) + types/ # TypeScript types +``` + +## Critical Rules + +### Tab Bar - NEVER CHANGE + +**NEVER set `backgroundColor: 'transparent'` on the tab bar.** + +Always use: +```typescript +backgroundColor: 'rgba(18, 18, 18, 0.85)', +``` + +The BlurView in `tabBarBackground` is unreliable. The solid rgba background acts as a fallback. + +Location: `app/(app)/_layout.tsx` - `tabBarStyle` + +### State Management + +- Use existing Zustand stores: `useAuthStore`, `useMatchStore`, `usePacksStore`, `useMessageStore`, `useSubscriptionStore` +- Access cross-store state via `getState()` (e.g., `useAuthStore.getState().user`) +- Clear related stores on sign out (see `signOut` in `useAuthStore`) + +### Supabase Queries + +- **Always use `.maybeSingle()` instead of `.single()`** when the row might not exist +- `.single()` throws an error if 0 or >1 rows are returned +- Check for null before performing operations + +### UI Components + +Use existing UI primitives from `src/components/ui/`: +- `GlassCard` - Glass-morphism card container +- `GlassButton` - Glass-styled button +- `GlassInput` - Glass-styled input field +- `GradientBackground` - Screen background gradient +- `ShimmerEffect` - Loading shimmer animation + +### Theme Usage + +Import from `src/theme/index.ts`: +```typescript +import { colors, gradients, spacing, radius, typography, shadows } from '@/theme'; +``` + +**DO NOT hardcode colors**. Always use theme values. + +### Response Handling + +- Responses use UPSERT with `onConflict: "user_id,question_id"` +- Match detection happens in `submit-response` edge function + +### Typing + +- Import shared types from `@/types` +- Import Supabase types from `@/types/supabase` + +### RevenueCat + +- RevenueCat service in `src/lib/revenuecat.ts` +- Does not work in Expo Go - check `revenueCatService.isAvailable()` first +- Initialize with user ID after auth + +### Analytics + +- Use `Events` from `src/lib/analytics` for tracking +- Track: `packEnabled`, `packDisabled`, `signOut`, etc. + +## Forbidden Actions + +1. **DO NOT** change tab bar background to transparent +2. **DO NOT** use `.single()` for queries that might return 0 rows +3. **DO NOT** hardcode colors - use theme values +4. **DO NOT** create new stores without integrating with auth sign-out cleanup +5. **DO NOT** bypass RevenueCat availability checks +6. **DO NOT** add navigation logic outside of expo-router diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index 0b63677..092c5c4 100755 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -1,6 +1,14 @@ import { useEffect } from "react"; import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, useWindowDimensions } from "react-native"; -import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated"; +import Animated, { + FadeInDown, + FadeInRight, + useSharedValue, + useAnimatedStyle, + withRepeat, + withTiming, + Easing, +} from "react-native-reanimated"; import { LinearGradient } from "expo-linear-gradient"; import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store"; import { Ionicons } from "@expo/vector-icons"; @@ -25,6 +33,28 @@ export default function HomeScreen() { const isWideScreen = width > MAX_CONTENT_WIDTH; const enabledPacksCount = packs.length; + // Subtle pulsating animation for the CTA icon + const pulseScale = useSharedValue(1); + + useEffect(() => { + const startPulse = () => { + pulseScale.value = 1; + pulseScale.value = withRepeat( + withTiming(1.1, { duration: 1000, easing: Easing.inOut(Easing.ease) }), + -1, + true + ); + }; + const timeout = setTimeout(startPulse, 100); + return () => clearTimeout(timeout); + }, []); + + const pulseAnimatedStyle = useAnimatedStyle(() => { + return { + transform: [{ scale: pulseScale.value }], + }; + }); + return ( - + - + diff --git a/apps/mobile/app/(app)/matches.tsx b/apps/mobile/app/(app)/matches.tsx index 43cac2b..5d95325 100755 --- a/apps/mobile/app/(app)/matches.tsx +++ b/apps/mobile/app/(app)/matches.tsx @@ -1,6 +1,6 @@ import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native"; import { useMatchStore, useAuthStore } from "../../src/store"; -import { useEffect, useCallback } from "react"; +import { useEffect, useCallback, useState } from "react"; import { useFocusEffect } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { useRouter } from "expo-router"; @@ -8,6 +8,8 @@ import { LinearGradient } from "expo-linear-gradient"; import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated"; import { GradientBackground, GlassCard } from "../../src/components/ui"; import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme"; +import MatchesTutorial from "../../src/components/MatchesTutorial"; +import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen"; const MAX_CONTENT_WIDTH = 500; @@ -17,6 +19,27 @@ export default function MatchesScreen() { const router = useRouter(); const { width } = useWindowDimensions(); const isWideScreen = width > MAX_CONTENT_WIDTH; + const [showTutorial, setShowTutorial] = useState(false); + + // Check if tutorial should be shown when screen is focused or matches change + useFocusEffect( + useCallback(() => { + const checkTutorial = async () => { + if (matches.length > 0) { + const seen = await hasSeenMatchesTutorial(); + if (!seen) { + setShowTutorial(true); + } + } + }; + checkTutorial(); + }, [matches.length]) + ); + + const handleTutorialComplete = async () => { + await markMatchesTutorialSeen(); + setShowTutorial(false); + }; useFocusEffect( useCallback(() => { @@ -187,6 +210,11 @@ export default function MatchesScreen() { } /> + + {/* Matches Tutorial Overlay */} + {showTutorial && ( + + )} ); } diff --git a/apps/mobile/app/(app)/onboarding.tsx b/apps/mobile/app/(app)/onboarding.tsx index 2d5e53c..5e3f387 100644 --- a/apps/mobile/app/(app)/onboarding.tsx +++ b/apps/mobile/app/(app)/onboarding.tsx @@ -313,7 +313,6 @@ export default function OnboardingScreen() { - I'm using Sauci to... {PURPOSE_OPTIONS.map((option) => ( (); const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore(); const { user, partner } = useAuthStore(); const { subscription } = useSubscriptionStore(); - const [expandedCategories, setExpandedCategories] = useState>(new Set()); + const [selectedFilter, setSelectedFilter] = useState('all'); const [showPaywall, setShowPaywall] = useState(false); const [showTeaser, setShowTeaser] = useState(false); const [teaserPack, setTeaserPack] = useState(null); @@ -32,6 +31,47 @@ export default function PacksScreen() { fetchPacks(); }, []); + // Apply category filter from URL param + useEffect(() => { + if (category) { + setSelectedFilter(category); + } + }, [category]); + + // Filter packs by selected filter (all, enabled, or category id) + const filteredPacks = useMemo(() => { + if (selectedFilter === 'all') return packs; + if (selectedFilter === 'enabled') return packs.filter(p => enabledPackIds.includes(p.id)); + // Otherwise it's a category id + return packs.filter(p => p.category_id === selectedFilter); + }, [packs, selectedFilter, enabledPackIds]); + + // Split packs into two columns for masonry layout + const { leftColumn, rightColumn } = useMemo(() => { + const left: { pack: QuestionPack; index: number }[] = []; + const right: { pack: QuestionPack; index: number }[] = []; + let leftHeight = 0; + let rightHeight = 0; + + filteredPacks.forEach((pack, index) => { + // Estimate card height based on description length + const baseHeight = 180; + const descLength = pack.description?.length || 0; + const extraHeight = Math.min(Math.ceil(descLength / 40) * 10, 40); + const cardHeight = baseHeight + extraHeight; + + if (leftHeight <= rightHeight) { + left.push({ pack, index }); + leftHeight += cardHeight; + } else { + right.push({ pack, index }); + rightHeight += cardHeight; + } + }); + + return { leftColumn: left, rightColumn: right }; + }, [filteredPacks]); + // Handle toggling a pack - show teaser if premium pack without access const handleTogglePack = useCallback(async (pack: QuestionPack) => { if (pack.is_premium && !hasPremiumAccess) { @@ -52,6 +92,17 @@ export default function PacksScreen() { } }, [hasPremiumAccess, togglePack]); + // Handle pack press - navigate or show teaser + const handlePackPress = useCallback((pack: QuestionPack) => { + const isPremiumLocked = pack.is_premium && !hasPremiumAccess; + if (isPremiumLocked) { + setTeaserPack(pack); + setShowTeaser(true); + } else { + router.push(`/pack/${pack.id}`); + } + }, [hasPremiumAccess]); + // Handle teaser unlock button - show paywall const handleTeaserUnlock = useCallback(() => { setShowTeaser(false); @@ -68,155 +119,10 @@ export default function PacksScreen() { const enabledPackCount = enabledPacks.length; const totalQuestions = enabledPacks.reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0); - // Group packs by category - const categoriesWithPacks = useMemo((): CategoryWithPacks[] => { - return categories - .map(category => ({ - ...category, - packs: packs.filter(p => p.category_id === category.id) - })) - .filter(cat => cat.packs.length > 0); - }, [packs, categories]); - - const toggleCategory = useCallback((categoryId: string) => { - setExpandedCategories(prev => { - const next = new Set(prev); - if (next.has(categoryId)) { - next.delete(categoryId); - } else { - next.add(categoryId); - } - return next; - }); - }, []); - - const renderPack = (item: QuestionPack, index: number) => { - const isEnabled = enabledPackIds.includes(item.id); - const isPremiumLocked = item.is_premium && !hasPremiumAccess; - - return ( - - - { - if (isPremiumLocked) { - setTeaserPack(item); - setShowTeaser(true); - } else { - router.push(`/pack/${item.id}`); - } - }} - > - - {item.icon || "๐Ÿ“ฆ"} - - - - - {item.name} - - {item.is_premium && ( - - - PRO - - )} - - - {isPremiumLocked ? "Upgrade to unlock" : item.description} - - - - handleTogglePack(item)} - disabled={isPremiumLocked} - trackColor={{ - false: colors.glass.background, - true: colors.primary, - }} - thumbColor={colors.text} - ios_backgroundColor={colors.glass.background} - style={isPremiumLocked ? { opacity: 0.4 } : undefined} - /> - - - ); - }; - - const renderCategory = ({ item: category, index }: { item: CategoryWithPacks; index: number }) => { - const isExpanded = expandedCategories.has(category.id); - const enabledInCategory = category.packs.filter(p => enabledPackIds.includes(p.id)).length; - - return ( - - - toggleCategory(category.id)} - > - {category.icon} - - {category.name} - - {category.packs.length} packs ยท {enabledInCategory} enabled - - - - - - {isExpanded && ( - - {category.packs.map((pack, idx) => renderPack(pack, idx))} - - )} - - - ); - }; - return ( + {/* Header */} - item.id} - contentContainerStyle={styles.list} + {/* Category Tabs */} + + + + + {/* Masonry Grid */} + } - ListEmptyComponent={ + contentContainerStyle={styles.scrollContent} + > + {filteredPacks.length === 0 ? ( No packs available - Check back later for new question packs! + {selectedFilter === 'enabled' + ? "No packs enabled yet. Enable some packs to see them here!" + : selectedFilter !== 'all' + ? "No packs in this category. Try selecting a different one." + : "Check back later for new question packs!"} - } - /> + ) : ( + + {/* Left Column */} + + {leftColumn.map(({ pack, index }, columnIndex) => ( + + handlePackPress(pack)} + onToggle={() => handleTogglePack(pack)} + /> + + ))} + + {/* Right Column */} + + {rightColumn.map(({ pack, index }, columnIndex) => ( + + handlePackPress(pack)} + onToggle={() => handleTogglePack(pack)} + /> + + ))} + + + )} + + + {/* Modals */} {teaserPack && ( + + { + await resetMatchesTutorial(); + Alert.alert("Success", "Matches tutorial has been reset. It will show again next time you visit the matches screen."); + }} + activeOpacity={0.7} + > + + + + + + Reset Matches Tutorial + Show the tutorial again + + + + + + + + { + if (!user?.id) return; + try { + const { error } = await supabase + .from('profiles') + .update({ onboarding_completed: false }) + .eq('id', user.id); + if (error) throw error; + await fetchUser(); + Alert.alert("Success", "Onboarding has been reset. You will see the onboarding flow on next app launch."); + } catch (error) { + Alert.alert("Error", "Failed to reset onboarding."); + } + }} + activeOpacity={0.7} + > + + + + + + Reset Onboarding + Show onboarding flow again + + + + + + )} diff --git a/apps/mobile/app/pack/[id].tsx b/apps/mobile/app/pack/[id].tsx index 3b348c0..48a3641 100644 --- a/apps/mobile/app/pack/[id].tsx +++ b/apps/mobile/app/pack/[id].tsx @@ -1,9 +1,14 @@ -import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator, Modal, Pressable } from "react-native"; +import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator, Modal, Pressable, Platform } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useEffect, useState } from "react"; +import { LinearGradient } from "expo-linear-gradient"; +import { BlurView } from "expo-blur"; import { supabase } from "../../src/lib/supabase"; -import { usePacksStore } from "../../src/store"; +import { usePacksStore, useSubscriptionStore, useAuthStore } from "../../src/store"; import { Ionicons } from "@expo/vector-icons"; +import Animated, { FadeInDown, FadeIn } from "react-native-reanimated"; +import { GradientBackground, GlassCard, ShimmerEffect } from "../../src/components/ui"; +import { colors, gradients, spacing, radius, typography, blur, shadows } from "../../src/theme"; import { Question } from "../../src/types"; type TooltipType = 'initiator' | 'coupleType' | null; @@ -19,15 +24,31 @@ const TOOLTIP_CONTENT = { }, }; +// Boutique gradient for question cards +const QUESTION_GRADIENTS: Array = [ + ['rgba(233, 69, 96, 0.15)', 'rgba(155, 89, 182, 0.15)'], + ['rgba(155, 89, 182, 0.15)', 'rgba(233, 69, 96, 0.15)'], + ['rgba(183, 110, 121, 0.12)', 'rgba(139, 69, 87, 0.12)'], + ['rgba(142, 68, 173, 0.12)', 'rgba(44, 62, 80, 0.12)'], +]; + export default function PackDetailsScreen() { const { id } = useLocalSearchParams(); const router = useRouter(); const { packs, fetchPacks } = usePacksStore(); + const { subscription } = useSubscriptionStore(); + const { user, partner } = useAuthStore(); const [questions, setQuestions] = useState([]); const [isLoading, setIsLoading] = useState(true); const [activeTooltip, setActiveTooltip] = useState(null); const pack = packs.find((p) => p.id === id); + const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser; + const isPremium = pack?.is_premium && hasPremiumAccess; + + // Get category for breadcrumb + const { categories } = usePacksStore(); + const category = categories.find(c => c.id === pack?.category_id); useEffect(() => { if (packs.length === 0) { @@ -53,311 +74,486 @@ export default function PackDetailsScreen() { } }; - const renderItem = ({ item }: { item: Question }) => ( - - {item.text} - {item.partner_text && ( - Partner role: {item.partner_text} - )} - - - {[...Array(5)].map((_, index) => ( - - ))} - -{/* Initiator indicator */} - {item.target_user_genders && item.target_user_genders.length > 0 ? ( - - setActiveTooltip('initiator')} - > - - {item.target_user_genders.map((g, i) => ( - 0 ? { marginLeft: 4 } : {}} - /> - ))} - - - - ) : null} - {/* Couple type indicator */} - {item.allowed_couple_genders && item.allowed_couple_genders.length > 0 ? ( - setActiveTooltip('coupleType')} - > - {item.allowed_couple_genders.map((g) => { - const icons = []; - if (g === 'male+male') { - icons.push('male', 'male'); - } else if (g === 'female+female') { - icons.push('female', 'female'); - } else { - icons.push('male', 'female'); - } + const useBlur = Platform.OS === 'ios'; - return ( - - {icons.map((icon, i) => ( - 0 ? { marginLeft: 4 } : {}} - /> - ))} - - ); - })} - - - ) : ( - setActiveTooltip('coupleType')} - > - - + const renderItem = ({ item, index }: { item: Question; index: number }) => { + const cardGradient = QUESTION_GRADIENTS[index % QUESTION_GRADIENTS.length]; + + return ( + + + {/* Subtle gradient overlay */} + + + + {item.text} + + {item.partner_text && ( + + + {item.partner_text} + + )} + + + {/* Intensity */} + + {[...Array(5)].map((_, i) => ( + + ))} + + + + {/* Initiator indicator */} + {item.target_user_genders && item.target_user_genders.length > 0 && ( + setActiveTooltip('initiator')} + > + + {item.target_user_genders.map((g, i) => ( + 0 ? { marginLeft: 4 } : {}} + /> + ))} + + + )} + + {/* Couple type indicator */} + setActiveTooltip('coupleType')} + > + {item.allowed_couple_genders && item.allowed_couple_genders.length > 0 ? ( + item.allowed_couple_genders.map((g) => { + const icons: ('male' | 'female')[] = []; + if (g === 'male+male') { + icons.push('male', 'male'); + } else if (g === 'female+female') { + icons.push('female', 'female'); + } else { + icons.push('male', 'female'); + } + + return ( + + {icons.map((icon, i) => ( + 0 ? { marginLeft: 2 } : {}} + /> + ))} + + ); + }) + ) : ( + + )} + + + - - + + + + ); + }; + + if (isLoading) { + return ( + + + + + + ); + } + + const heroContent = ( + + {/* Background gradient */} + + + {/* Glass overlay */} + {useBlur ? ( + + ) : ( + + )} + + {/* Silk texture */} + + + {/* Back button */} + router.back()} style={styles.backButton}> + + + + + + {/* Pack info */} + + + {pack?.icon || "๐Ÿ“ฆ"} + + + {pack?.is_premium && ( + + + PRO + )} + + {/* Category breadcrumb - tappable to filter by category */} + {category && ( + router.replace(`/(app)/packs?category=${category.id}`)} + activeOpacity={0.7} + > + {category.icon} {category.name} + + + )} + + {pack?.name || "Pack Details"} + {pack?.description} + + + {questions.length} questions + ); - if (isLoading) { - return ( - - - - ); - } - return ( - - - router.back()} style={styles.backButton}> - - - {pack?.name || "Pack Details"} - - + + + {/* Hero Section */} + + {isPremium ? ( + + {heroContent} + + ) : ( + + {heroContent} + + )} + - {pack && ( - - - {pack.icon || "๐Ÿ“ฆ"} - - {pack.description} - - )} + {/* Questions List */} + item.id} + contentContainerStyle={styles.list} + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + + No questions in this pack yet + + } + /> - - Questions ({questions.length}) - - - item.id} - contentContainerStyle={styles.list} - /> - - {/* Tooltip Modal */} - setActiveTooltip(null)} - > - setActiveTooltip(null)} + {/* Tooltip Modal */} + setActiveTooltip(null)} > - - - {activeTooltip && TOOLTIP_CONTENT[activeTooltip].title} - - - {activeTooltip && TOOLTIP_CONTENT[activeTooltip].description} - - setActiveTooltip(null)} - > - Got it - - - - - + setActiveTooltip(null)} + > + + + + {activeTooltip && TOOLTIP_CONTENT[activeTooltip].title} + + + {activeTooltip && TOOLTIP_CONTENT[activeTooltip].description} + + setActiveTooltip(null)} + > + + Got it + + + + + + + ); } const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: "#1a1a2e", }, loadingContainer: { flex: 1, justifyContent: "center", alignItems: "center", - backgroundColor: "#1a1a2e", }, - header: { - paddingTop: 60, - paddingHorizontal: 24, - paddingBottom: 24, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - borderBottomWidth: 1, - borderBottomColor: "#16213e", + heroContainer: { + borderBottomLeftRadius: radius.xl, + borderBottomRightRadius: radius.xl, + overflow: 'hidden', + ...shadows.lg, + }, + heroInner: { + paddingTop: 50, + paddingBottom: spacing.lg, + minHeight: 260, + }, + heroBlur: { + backgroundColor: 'rgba(22, 33, 62, 0.4)', + }, + heroBlurFallback: { + backgroundColor: 'rgba(22, 33, 62, 0.6)', }, backButton: { - padding: 4, + position: 'absolute', + top: 50, + left: spacing.md, + zIndex: 10, }, - title: { - fontSize: 20, - fontWeight: "bold", - color: "#fff", + backButtonInner: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + borderColor: colors.glass.border, }, - packInfo: { - padding: 24, - alignItems: "center", - borderBottomWidth: 1, - borderBottomColor: "#16213e", + heroContent: { + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingTop: spacing.xl, }, iconContainer: { - width: 64, - height: 64, - borderRadius: 32, - backgroundColor: "rgba(255, 255, 255, 0.05)", - justifyContent: "center", - alignItems: "center", - marginBottom: 16, + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + justifyContent: 'center', + alignItems: 'center', + marginBottom: spacing.md, + borderWidth: 2, + borderColor: 'rgba(255, 255, 255, 0.15)', + }, + iconContainerPremium: { + borderColor: colors.premium.gold, + backgroundColor: colors.premium.goldLight, }, emoji: { - fontSize: 32, + fontSize: 40, + }, + proBadge: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.premium.gold, + paddingHorizontal: spacing.sm, + paddingVertical: 4, + borderRadius: radius.sm, + gap: 4, + marginBottom: spacing.sm, + }, + proBadgeText: { + ...typography.caption1, + color: colors.text, + fontWeight: '700', + }, + breadcrumb: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + marginBottom: spacing.xs, + }, + breadcrumbText: { + ...typography.caption1, + color: colors.textSecondary, + }, + title: { + ...typography.title2, + color: colors.text, + textAlign: 'center', + marginBottom: spacing.xs, }, description: { - fontSize: 16, - color: "#ccc", - textAlign: "center", + ...typography.body, + color: colors.textSecondary, + textAlign: 'center', lineHeight: 24, + paddingHorizontal: spacing.md, }, - sectionHeader: { - padding: 24, - paddingBottom: 8, + statsBadge: { + marginTop: spacing.md, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: radius.full, + borderWidth: 1, + borderColor: colors.glass.border, }, - sectionTitle: { - fontSize: 18, - fontWeight: "bold", - color: "#e94560", + statsText: { + ...typography.subhead, + color: colors.textSecondary, + fontWeight: '500', }, list: { - padding: 24, + padding: spacing.md, + paddingBottom: Platform.OS === 'ios' ? 100 : 80, }, - card: { - backgroundColor: "#16213e", - borderRadius: 12, - padding: 16, - marginBottom: 12, - borderWidth: 1, - borderColor: "#0f3460", + questionCard: { + marginBottom: spacing.md, + overflow: 'hidden', + }, + questionContent: { + padding: spacing.md, }, questionText: { - fontSize: 16, - color: "#fff", - marginBottom: 8, - lineHeight: 22, + ...typography.body, + color: colors.text, + marginBottom: spacing.sm, + lineHeight: 24, }, - intensityContainer: { - flexDirection: "row", + partnerTextContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + marginBottom: spacing.sm, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.sm, + backgroundColor: colors.premium.roseLight, + borderRadius: radius.sm, + alignSelf: 'flex-start', }, partnerText: { - fontSize: 14, - color: "#aaa", - fontStyle: "italic", - marginBottom: 8, + ...typography.caption1, + color: colors.premium.rose, + fontStyle: 'italic', }, footerContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - marginTop: 8, + marginTop: spacing.xs, }, - targetsContainer: { + intensityContainer: { flexDirection: 'row', - flexWrap: 'wrap', - alignItems: 'center', - gap: 6, + }, + badgesContainer: { + flexDirection: 'row', + gap: spacing.sm, }, targetBadge: { flexDirection: 'row', alignItems: 'center', - backgroundColor: 'rgba(255, 255, 255, 0.1)', - paddingHorizontal: 10, - paddingVertical: 6, - borderRadius: 6, + backgroundColor: 'rgba(255, 255, 255, 0.08)', + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radius.sm, borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.2)', + borderColor: colors.glass.border, }, - initiatorBadge: { - backgroundColor: 'rgba(243, 156, 18, 0.15)', - borderColor: 'rgba(243, 156, 18, 0.3)', + coupleIconGroup: { + flexDirection: 'row', + alignItems: 'center', + }, + emptyContainer: { + alignItems: 'center', + paddingVertical: spacing.xxl, + gap: spacing.md, + }, + emptyText: { + ...typography.body, + color: colors.textSecondary, }, modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.7)', justifyContent: 'center', alignItems: 'center', - padding: 24, + padding: spacing.lg, }, tooltipContainer: { - backgroundColor: '#16213e', - borderRadius: 16, - padding: 24, maxWidth: 320, - borderWidth: 1, - borderColor: '#0f3460', + overflow: 'hidden', }, tooltipTitle: { - fontSize: 18, - fontWeight: 'bold', - color: '#fff', - marginBottom: 12, + ...typography.title3, + color: colors.text, + marginBottom: spacing.sm, }, tooltipDescription: { - fontSize: 14, - color: '#ccc', - lineHeight: 22, - marginBottom: 20, + ...typography.body, + color: colors.textSecondary, + lineHeight: 24, + marginBottom: spacing.lg, }, tooltipButton: { - backgroundColor: '#e94560', - borderRadius: 8, - paddingVertical: 12, + borderRadius: radius.md, + overflow: 'hidden', + }, + tooltipButtonGradient: { + paddingVertical: spacing.sm, alignItems: 'center', }, tooltipButtonText: { - fontSize: 16, - fontWeight: '600', - color: '#fff', + ...typography.headline, + color: colors.text, }, }); diff --git a/apps/mobile/src/components/BoutiquePackCard.tsx b/apps/mobile/src/components/BoutiquePackCard.tsx new file mode 100644 index 0000000..cc5679c --- /dev/null +++ b/apps/mobile/src/components/BoutiquePackCard.tsx @@ -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 = [ + 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 = ( + + {/* Background gradient */} + + + {/* Glass overlay */} + {useBlur ? ( + + ) : ( + + )} + + {/* Silk texture effect */} + + + {/* Top highlight */} + + + {/* Content */} + + {/* PRO Badge (top-right) */} + {pack.is_premium && ( + + + PRO + + )} + + {/* Pack Icon */} + + {pack.icon || '๐Ÿ“ฆ'} + + + {/* Pack Name */} + + {pack.name} + + + {/* Description */} + + {isPremiumLocked ? 'Unlock with Pro' : pack.description} + + + {/* Footer */} + + + {questionCount} + questions + + + + + + + {/* Lock overlay for premium locked packs */} + {isPremiumLocked && ( + + + + + + )} + + ); + + const wrappedCard = pack.is_premium && !isPremiumLocked ? ( + + {cardContent} + + ) : ( + cardContent + ); + + return ( + + {wrappedCard} + + ); +} + +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; diff --git a/apps/mobile/src/components/CategoryTabs.tsx b/apps/mobile/src/components/CategoryTabs.tsx new file mode 100644 index 0000000..819aeed --- /dev/null +++ b/apps/mobile/src/components/CategoryTabs.tsx @@ -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 === 'โœ“' ? ( + + ) : icon ? ( + {icon} + ) : null} + {label} + {badge !== undefined && badge > 0 && ( + + {badge} + + )} + + ); + + return ( + + {isSelected ? ( + + {tabInnerContent} + + ) : useBlur ? ( + + + {tabInnerContent} + + + ) : ( + + {tabInnerContent} + + )} + + ); +} + +export function CategoryTabs({ + categories, + selectedFilter, + onSelectFilter, + enabledCount = 0, +}: CategoryTabsProps) { + return ( + + + {/* All tab */} + onSelectFilter('all')} + /> + + {/* Enabled tab */} + onSelectFilter('enabled')} + badge={enabledCount} + /> + + {/* Category tabs */} + {categories.map((category) => ( + onSelectFilter(category.id)} + /> + ))} + + + ); +} + +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; diff --git a/apps/mobile/src/components/MatchesTutorial.tsx b/apps/mobile/src/components/MatchesTutorial.tsx new file mode 100644 index 0000000..24ef3f6 --- /dev/null +++ b/apps/mobile/src/components/MatchesTutorial.tsx @@ -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 ( + + + + {/* Skip button */} + + Skip + + + {/* Central illustration area */} + + + {/* Pulse ring behind icon */} + + + + + + {/* Main icon container */} + + + + + + + + {/* Match type badges - show on first step */} + {currentStep === 0 && ( + + + + + YES + YES + + + + + YES + MAYBE + + + + )} + + {/* Chat demo - show on second step */} + {currentStep === 1 && ( + + + I'd love to try this! + + + Me too! When? + + + )} + + {/* Photos demo - show on third step */} + {currentStep === 2 && ( + + + + + + + + + + + + Private & Secure + + + )} + + + + {/* Text content */} + + {currentStepData.title} + {currentStepData.description} + + + {/* Progress dots */} + + {STEPS.map((_, index) => ( + + ))} + + + {/* Next button */} + [ + styles.nextButton, + pressed && styles.nextButtonPressed + ]}> + + + {currentStep < STEPS.length - 1 ? "Next" : "Got it!"} + + + + + + + + ); +} + +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, + }, +}); diff --git a/apps/mobile/src/components/SwipeTutorial.tsx b/apps/mobile/src/components/SwipeTutorial.tsx index a6108c1..7b95e9f 100644 --- a/apps/mobile/src/components/SwipeTutorial.tsx +++ b/apps/mobile/src/components/SwipeTutorial.tsx @@ -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(); }; diff --git a/apps/mobile/src/components/ui/ShimmerEffect.tsx b/apps/mobile/src/components/ui/ShimmerEffect.tsx new file mode 100644 index 0000000..2f00ba3 --- /dev/null +++ b/apps/mobile/src/components/ui/ShimmerEffect.tsx @@ -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 ( + + {children} + {enabled && ( + + + + )} + + ); +} + +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; diff --git a/apps/mobile/src/components/ui/index.ts b/apps/mobile/src/components/ui/index.ts index dcf048f..b82a49f 100644 --- a/apps/mobile/src/components/ui/index.ts +++ b/apps/mobile/src/components/ui/index.ts @@ -2,3 +2,4 @@ export { GradientBackground } from './GradientBackground'; export { GlassCard } from './GlassCard'; export { GlassButton } from './GlassButton'; export { GlassInput } from './GlassInput'; +export { ShimmerEffect } from './ShimmerEffect'; diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts index 3f1a8d4..44f9a99 100644 --- a/apps/mobile/src/lib/analytics.ts +++ b/apps/mobile/src/lib/analytics.ts @@ -166,4 +166,14 @@ export const Events = { // Invite events inviteCodeCopied: () => logEvent("invite_code_copied"), + + // Tutorial events + tutorialStarted: (tutorial: "swipe" | "matches") => + logEvent("tutorial_started", { tutorial }), + tutorialStepViewed: (tutorial: "swipe" | "matches", step: number, stepName: string) => + logEvent("tutorial_step_viewed", { tutorial, step, step_name: stepName }), + tutorialCompleted: (tutorial: "swipe" | "matches") => + logEvent("tutorial_completed", { tutorial }), + tutorialSkipped: (tutorial: "swipe" | "matches", atStep: number) => + logEvent("tutorial_skipped", { tutorial, at_step: atStep }), }; diff --git a/apps/mobile/src/lib/matchesTutorialSeen.ts b/apps/mobile/src/lib/matchesTutorialSeen.ts new file mode 100644 index 0000000..4ff4d20 --- /dev/null +++ b/apps/mobile/src/lib/matchesTutorialSeen.ts @@ -0,0 +1,51 @@ +import * as SecureStore from "expo-secure-store"; +import { Platform } from "react-native"; + +const STORAGE_KEY = "matches_tutorial_seen"; + +async function getStorage(): Promise { + try { + let data: string | null = null; + + if (Platform.OS === "web") { + if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") { + data = window.localStorage.getItem(STORAGE_KEY); + } + } else { + data = await SecureStore.getItemAsync(STORAGE_KEY); + } + + return data === "true"; + } catch (error) { + console.error("Error reading matches tutorial state:", error); + } + return false; +} + +async function setStorage(seen: boolean): Promise { + try { + const data = seen ? "true" : "false"; + + if (Platform.OS === "web") { + if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") { + window.localStorage.setItem(STORAGE_KEY, data); + } + } else { + await SecureStore.setItemAsync(STORAGE_KEY, data); + } + } catch (error) { + console.error("Error saving matches tutorial state:", error); + } +} + +export async function hasSeenMatchesTutorial(): Promise { + return getStorage(); +} + +export async function markMatchesTutorialSeen(): Promise { + await setStorage(true); +} + +export async function resetMatchesTutorial(): Promise { + await setStorage(false); +} diff --git a/apps/mobile/src/theme/index.ts b/apps/mobile/src/theme/index.ts index 26580bb..da38ac0 100644 --- a/apps/mobile/src/theme/index.ts +++ b/apps/mobile/src/theme/index.ts @@ -28,8 +28,8 @@ export const colors = { // Text text: '#ffffff', - textSecondary: 'rgba(255, 255, 255, 0.6)', - textTertiary: 'rgba(255, 255, 255, 0.4)', + textSecondary: 'rgba(255, 255, 255, 0.7)', // Bumped from 0.6 for better contrast + textTertiary: 'rgba(255, 255, 255, 0.5)', // Bumped from 0.4 for accessibility // Semantic success: '#2ECC71', @@ -38,6 +38,17 @@ export const colors = { warningLight: 'rgba(243, 156, 18, 0.15)', error: '#E74C3C', errorLight: 'rgba(231, 76, 60, 0.15)', + + // Premium/Luxury + premium: { + gold: '#D4AF37', + goldLight: 'rgba(212, 175, 55, 0.15)', + goldGlow: 'rgba(212, 175, 55, 0.3)', + champagne: '#F7E7CE', + champagneLight: 'rgba(247, 231, 206, 0.1)', + rose: '#E8A4AE', // Lightened for accessibility (was #B76E79, now ~5.5:1 contrast) + roseLight: 'rgba(232, 164, 174, 0.15)', + }, }; export const gradients = { @@ -51,6 +62,18 @@ export const gradients = { success: ['#2ECC71', '#27ae60'], warning: ['#F39C12', '#e67e22'], error: ['#E74C3C', '#c0392b'], + // Premium/Boutique gradients + premiumGold: ['#D4AF37', '#B8860B'], + premiumRose: ['#B76E79', '#8B4557'], + silkLight: ['rgba(255, 255, 255, 0.08)', 'rgba(255, 255, 255, 0.02)'], + laceTint: ['rgba(233, 69, 96, 0.15)', 'rgba(155, 89, 182, 0.15)'], + // Boutique pack card backgrounds + boutiqueRose: ['rgba(233, 69, 96, 0.4)', 'rgba(155, 89, 182, 0.4)'], + boutiquePurple: ['rgba(155, 89, 182, 0.4)', 'rgba(233, 69, 96, 0.4)'], + boutiqueDusty: ['rgba(183, 110, 121, 0.35)', 'rgba(139, 69, 87, 0.35)'], + boutiqueGold: ['rgba(212, 175, 55, 0.25)', 'rgba(184, 134, 11, 0.25)'], + boutiqueMidnight: ['rgba(22, 33, 62, 0.8)', 'rgba(13, 13, 26, 0.8)'], + boutiqueAmethyst: ['rgba(142, 68, 173, 0.4)', 'rgba(44, 62, 80, 0.4)'], }; export const spacing = { diff --git a/apps/supabase/agents.md b/apps/supabase/agents.md new file mode 100644 index 0000000..c470255 --- /dev/null +++ b/apps/supabase/agents.md @@ -0,0 +1,136 @@ +# Supabase - Agent Guidelines + +**STRICT RULES - DO NOT VIOLATE** + +## Architecture + +This contains Supabase configuration: +- **Migrations**: SQL migrations in `migrations/` +- **Edge Functions**: Deno functions in `functions/` +- **Email Templates**: Auth email templates in `email-templates/` +- **Config**: Local development config in `config.toml` + +## Directory Structure + +``` +functions/ + delete-relationship/ # Delete couple relationship + manage-couple/ # Join/leave couple + revenuecat-webhook/ # Subscription webhook handler + send-message-notification/ # Push notification for messages + send-notification/ # Push notification for matches + submit-response/ # Save response, check matches + sync-subscription/ # Sync RevenueCat status +migrations/ # SQL migrations +email-templates/ # Auth email templates +config.toml # Local dev configuration +``` + +## Critical Rules + +### Edge Function Deployment + +**ALWAYS use `verify_jwt: false`** when deploying via MCP tools. + +Supabase's built-in JWT verification causes 401 errors. Handle auth manually: + +```typescript +const { data: { user }, error: authError } = await supabase.auth.getUser( + authHeader.replace("Bearer ", "") +); +``` + +### Edge Function Pattern + +All edge functions MUST follow this structure: + +```typescript +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +}; + +Deno.serve(async (req) => { + // Handle CORS preflight + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! + ); + + // Manual auth verification + const authHeader = req.headers.get("Authorization"); + if (!authHeader) { + return new Response( + JSON.stringify({ error: "Missing authorization header" }), + { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + const { data: { user }, error: authError } = await supabase.auth.getUser( + authHeader.replace("Bearer ", "") + ); + + // ... function logic + } catch (error) { + return new Response( + JSON.stringify({ error: "Internal server error" }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } +}); +``` + +### Function Auth Methods + +| Function | Auth Method | +|----------|-------------| +| `manage-couple` | Manual via getUser() | +| `submit-response` | Manual via getUser() | +| `sync-subscription` | Manual via getUser() | +| `delete-relationship` | Manual via getUser() | +| `revenuecat-webhook` | Webhook signature validation | +| `send-notification` | Internal trigger (no auth) | +| `send-message-notification` | Internal trigger (no auth) | + +### Migration Rules + +- Use `apply_migration` MCP tool for DDL operations +- Never hardcode generated IDs in data migrations +- Always verify target environment (prod vs non-prod) before running + +### Database Patterns + +- Use UPSERT with `onConflict` for responses: `onConflict: "user_id,question_id"` +- Use UPSERT for matches: `onConflict: "couple_id,question_id"` +- RLS policies enforce couple-level data isolation + +### Profile Creation + +The `on_auth_user_created` trigger automatically creates a profile. Never assume a profile exists without checking - check for null before operations. + +## MCP Environments + +| Environment | MCP Server | +|-------------|------------| +| Production | `mcp__sauci-prod__*` | +| Non-Production | `mcp__sauci-non-prod__*` | + +**ALWAYS verify environment before destructive operations.** + +## Forbidden Actions + +1. **DO NOT** use `verify_jwt: true` when deploying functions via MCP +2. **DO NOT** skip CORS headers in responses +3. **DO NOT** use service role key on client-side +4. **DO NOT** hardcode IDs in migrations +5. **DO NOT** assume profiles exist - always check for null +6. **DO NOT** run migrations on production without explicit confirmation +7. **DO NOT** expose internal error details in responses diff --git a/apps/supabase/migrations/20251230000000_v1_initial_schema.sql b/apps/supabase/migrations/20251230000000_v1_initial_schema.sql index bd42647..5c34fe0 100644 --- a/apps/supabase/migrations/20251230000000_v1_initial_schema.sql +++ b/apps/supabase/migrations/20251230000000_v1_initial_schema.sql @@ -1028,6 +1028,7 @@ CREATE POLICY "Users can view own feedback screenshots" ); -- Chat media policies +-- IMPORTANT: Use storage.objects.name (not just "name") to avoid ambiguity with profiles.name CREATE POLICY "Users can view chat media in their matches" ON storage.objects FOR SELECT TO authenticated USING ( @@ -1035,7 +1036,7 @@ CREATE POLICY "Users can view chat media in their matches" AND EXISTS ( SELECT 1 FROM matches m JOIN profiles p ON p.couple_id = m.couple_id - WHERE (storage.foldername(name))[1] = m.id::text + WHERE (storage.foldername(storage.objects.name))[1] = m.id::text AND p.id = auth.uid() ) ); @@ -1047,7 +1048,7 @@ CREATE POLICY "Users can upload chat media to their matches" AND EXISTS ( SELECT 1 FROM matches m JOIN profiles p ON p.couple_id = m.couple_id - WHERE (storage.foldername(name))[1] = m.id::text + WHERE (storage.foldername(storage.objects.name))[1] = m.id::text AND p.id = auth.uid() ) ); @@ -1059,7 +1060,7 @@ CREATE POLICY "Users can delete their own chat media" AND EXISTS ( SELECT 1 FROM matches m JOIN profiles p ON p.couple_id = m.couple_id - WHERE (storage.foldername(name))[1] = m.id::text + WHERE (storage.foldername(storage.objects.name))[1] = m.id::text AND p.id = auth.uid() ) ); diff --git a/apps/supabase/migrations/20251231000000_fix_chat_media_storage_policies.sql b/apps/supabase/migrations/20251231000000_fix_chat_media_storage_policies.sql new file mode 100644 index 0000000..28e34ec --- /dev/null +++ b/apps/supabase/migrations/20251231000000_fix_chat_media_storage_policies.sql @@ -0,0 +1,46 @@ +-- Fix storage policies that incorrectly reference p.name instead of storage.objects.name +-- Bug: Using unqualified "name" in a subquery with profiles table causes Postgres to resolve +-- it to profiles.name instead of storage.objects.name +-- Fix: Explicitly qualify as storage.objects.name to reference the storage object's file path + +-- Drop existing broken policies +DROP POLICY IF EXISTS "Users can upload chat media to their matches" ON storage.objects; +DROP POLICY IF EXISTS "Users can view chat media in their matches" ON storage.objects; +DROP POLICY IF EXISTS "Users can delete their own chat media" ON storage.objects; + +-- Recreate with explicit table qualification to avoid column name ambiguity +CREATE POLICY "Users can upload chat media to their matches" + ON storage.objects FOR INSERT TO authenticated + WITH CHECK ( + bucket_id = 'chat-media' + AND EXISTS ( + SELECT 1 FROM matches m + JOIN profiles p ON p.couple_id = m.couple_id + WHERE (storage.foldername(storage.objects.name))[1] = m.id::text + AND p.id = auth.uid() + ) + ); + +CREATE POLICY "Users can view chat media in their matches" + ON storage.objects FOR SELECT TO authenticated + USING ( + bucket_id = 'chat-media' + AND EXISTS ( + SELECT 1 FROM matches m + JOIN profiles p ON p.couple_id = m.couple_id + WHERE (storage.foldername(storage.objects.name))[1] = m.id::text + AND p.id = auth.uid() + ) + ); + +CREATE POLICY "Users can delete their own chat media" + ON storage.objects FOR DELETE TO authenticated + USING ( + bucket_id = 'chat-media' + AND EXISTS ( + SELECT 1 FROM matches m + JOIN profiles p ON p.couple_id = m.couple_id + WHERE (storage.foldername(storage.objects.name))[1] = m.id::text + AND p.id = auth.uid() + ) + ); diff --git a/apps/web/agents.md b/apps/web/agents.md new file mode 100644 index 0000000..215e668 --- /dev/null +++ b/apps/web/agents.md @@ -0,0 +1,86 @@ +# Web App - Agent Guidelines + +**STRICT RULES - DO NOT VIOLATE** + +## Architecture + +This is a Next.js 14+ marketing/landing website using: +- **Framework**: Next.js with App Router +- **Styling**: Tailwind CSS +- **Analytics**: PostHog +- **Routing**: File-based routing in `app/` directory + +## Directory Structure + +``` +app/ + layout.tsx # Root layout with PostHog provider + page.tsx # Home/landing page + globals.css # Global styles + providers.tsx # PostHog provider wrapper + PostHogPageView.tsx # Page view tracking component + privacy/ # Privacy policy + terms/ # Terms of service + redeem/ # Code redemption page +components/ # Reusable components +public/ # Static assets (images, fonts) +``` + +## Critical Rules + +### App Router + +This uses Next.js App Router (not Pages Router): +- Server Components by default +- Use `'use client'` directive for client components +- Metadata exported from layout.tsx and page.tsx + +### PostHog Integration + +PostHog is configured at root level: +- Provider in `providers.tsx` +- Page view tracking in `PostHogPageView.tsx` +- Wrap all pages in `` + +### SEO & Metadata + +Metadata is defined in `layout.tsx`: +```typescript +export const metadata: Metadata = { + title: 'Sauci - Ignite Your Connection', + description: '...', + openGraph: { ... }, + twitter: { ... }, +}; +``` + +Update metadata when adding new pages. + +### Styling + +- Use Tailwind CSS classes +- Global styles in `globals.css` +- Follow mobile-first responsive design + +### Static Pages + +Privacy and Terms pages are static content: +- `/privacy` - Privacy policy +- `/terms` - Terms of service + +Keep these updated with legal requirements. + +### Environment Variables + +Required environment variables: +- `NEXT_PUBLIC_POSTHOG_KEY` - PostHog API key +- `NEXT_PUBLIC_POSTHOG_HOST` - PostHog host + +## Forbidden Actions + +1. **DO NOT** remove PostHog tracking without approval +2. **DO NOT** use Pages Router patterns (use App Router) +3. **DO NOT** add server-side API routes that expose sensitive data +4. **DO NOT** forget to update metadata for new pages +5. **DO NOT** skip mobile responsiveness +6. **DO NOT** commit .env files with real API keys diff --git a/packages/shared/agents.md b/packages/shared/agents.md new file mode 100644 index 0000000..d400d85 --- /dev/null +++ b/packages/shared/agents.md @@ -0,0 +1,76 @@ +# Shared Package - Agent Guidelines + +**STRICT RULES - DO NOT VIOLATE** + +## Architecture + +This is a shared TypeScript package (`@sauci/shared`) containing: +- **Types**: Shared TypeScript type definitions +- **Package Name**: `@sauci/shared` + +## Directory Structure + +``` +src/ + index.ts # Main export file + types/ # Type definitions +package.json # Package configuration +tsconfig.json # TypeScript config +``` + +## Critical Rules + +### Purpose + +This package exists ONLY for sharing types across apps. It should contain: +- TypeScript interfaces +- Type definitions +- Enums +- Constants (if truly shared) + +**NO runtime code** - types only. + +### Exports + +All exports must go through `src/index.ts`: +```typescript +export * from './types'; +``` + +### Usage in Apps + +Import in consuming apps: +```typescript +import type { Profile, Couple, Match } from '@sauci/shared'; +``` + +### Type Definitions + +Types should match database schema. Core entities: +- `Profile` - User profile extending Supabase auth +- `Couple` - Two users linked via invite code +- `QuestionPack` - Collection of questions +- `Question` - Individual question with intensity +- `Response` - User's answer (yes/no/maybe) +- `Match` - Created when both partners answer positively + +### Adding Types + +When adding new types: +1. Add to appropriate file in `src/types/` +2. Export from `src/types/index.ts` +3. Re-export from `src/index.ts` +4. Run `npm run build` from root to verify + +### Versioning + +Types are internal - no npm publishing. Changes propagate automatically via Turborepo workspace linking. + +## Forbidden Actions + +1. **DO NOT** add runtime code (functions, classes with logic) +2. **DO NOT** add dependencies (except dev dependencies) +3. **DO NOT** create circular dependencies between types +4. **DO NOT** duplicate types that exist in Supabase generated types +5. **DO NOT** break backwards compatibility without updating all consumers +6. **DO NOT** add app-specific types (keep them generic/shared)