diff --git a/CLAUDE.md b/CLAUDE.md index b6200f3..4731aec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Important: Design Guidelines + +**Always read `apps/mobile/DESIGN.md` before making UI/UX changes to the mobile app.** This file contains the design system, visual language, and styling patterns that must be followed for consistency. Agents and subagents should read this file when: +- Creating new screens or components +- Modifying existing UI elements +- Adding animations or visual effects +- Working with colors, typography, or spacing + ## Project Overview Sauci is a couples intimacy app featuring swipeable question packs with partner matching. Users swipe on questions (yes/no/maybe), and when both partners answer positively, a "match" is created that unlocks a chat thread for that question. diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md new file mode 100644 index 0000000..e4ec17e --- /dev/null +++ b/apps/mobile/DESIGN.md @@ -0,0 +1,354 @@ +# Sauci Design System + +A boutique, premium design framework for the Sauci couples intimacy app. + +## Brand Identity + +**Aesthetic:** Boutique lingerie store meets modern dating app. Think Ann Summers sophistication - sensual but tasteful, premium but accessible, intimate but playful. + +**Mood:** Romantic, luxurious, warm, inviting, confident + +**Key Principles:** + +- Glass-morphism for depth and elegance +- Dark backgrounds with warm accents +- Primary gradient (rose → purple) as the core brand signature +- Gold reserved for premium/dares features only +- Gradients for visual interest and brand recognition + +--- + +## Color Hierarchy + +### 1. Brand Colors (Primary Palette) + +The core brand identity. Used for primary actions, key UI elements, and brand recognition. + +| Name | Hex | RGB | Usage | +|------|-----|-----|-------| +| **Primary** | `#e94560` | 233, 69, 96 | Primary buttons, active states, key accents | +| **Primary Dark** | `#c73a52` | 199, 58, 82 | Pressed states, darker variants | +| **Secondary** | `#9b59b6` | 155, 89, 182 | Secondary accents, gradient endpoints | +| **Secondary Dark** | `#8e44ad` | 142, 68, 173 | Pressed states, darker variants | + +**Primary Gradient:** `#e94560 → #9b59b6` (Rose to Purple) +- Direction: Left to right (horizontal) or top to bottom (vertical) +- Use for: Primary buttons, match celebrations, brand moments + +### 2. Premium/Luxury Colors + +The boutique palette. Used for premium features, upgrades, and luxury moments. + +| Name | Hex | RGB | Usage | +|------|-----|-----|-------| +| **Gold** | `#D4AF37` | 212, 175, 55 | Premium badges, Dares feature, luxury accents | +| **Gold Dark** | `#B8860B` | 184, 134, 11 | Gold gradient endpoint | +| **Rose** | `#E8A4AE` | 232, 164, 174 | Quiz feature, soft premium accents | +| **Rose Dark** | `#D4919B` | 212, 145, 155 | Rose gradient endpoint | +| **Champagne** | `#F7E7CE` | 247, 231, 206 | Highlights, premium text accents | + +### 3. Feature Colors + +Each core feature has a designated accent color for consistency and recognition. + +| Feature | Primary Color | Hex | Gradient | +|---------|---------------|-----|----------| +| **Swipe/Match** | Primary | `#e94560` | Primary gradient (rose → purple) | +| **Quiz** | Rose | `#E8A4AE` | `#E8A4AE → #D4919B` | +| **Dares** | Gold | `#D4AF37` | `#D4AF37 → #B8860B` | +| **Chat** | Primary | `#e94560` | Primary gradient | +| **Profile** | Secondary | `#9b59b6` | Secondary gradient | + +**Usage Rules:** +- Feature icons use their designated color +- Feature headers/titles can use the color as an accent +- Feature-specific buttons should use the feature color +- Keep the feature color consistent across all screens for that feature + +### 4. Background Colors + +Dark, rich backgrounds that make content pop. + +| Name | Hex | RGBA | Usage | +|------|-----|------|-------| +| **Background** | `#0d0d1a` | 13, 13, 26 | Primary app background | +| **Background Light** | `#1a1a2e` | 26, 26, 46 | Cards, elevated surfaces | +| **Surface** | — | `rgba(22, 33, 62, 0.6)` | Glass overlays | +| **Surface Solid** | `#16213e` | 22, 33, 62 | Non-transparent surfaces | + +### 5. Glass/Surface Colors + +For the glass-morphism effect that defines the UI. + +| Name | RGBA | Usage | +|------|------|-------| +| **Glass Background** | `rgba(22, 33, 62, 0.4)` | Standard glass cards | +| **Glass Background Heavy** | `rgba(22, 33, 62, 0.6)` | Emphasized glass cards | +| **Glass Border** | `rgba(255, 255, 255, 0.08)` | Subtle borders | +| **Glass Border Light** | `rgba(255, 255, 255, 0.12)` | Prominent borders | +| **Glass Highlight** | `rgba(255, 255, 255, 0.05)` | Top edge shine | + +### 6. Text Colors + +| Name | RGBA | Usage | +|------|------|-------| +| **Text** | `#ffffff` | Primary text, headings | +| **Text Secondary** | `rgba(255, 255, 255, 0.7)` | Secondary text, descriptions | +| **Text Tertiary** | `rgba(255, 255, 255, 0.5)` | Disabled, hints, captions | + +### 7. Semantic Colors + +For system feedback and states. + +| Name | Hex | Usage | +|------|-----|-------| +| **Success** | `#2ECC71` | Yes responses, confirmations, positive states | +| **Warning** | `#F39C12` | Maybe responses, cautions | +| **Error** | `#E74C3C` | No responses, errors, destructive actions | +| **Muted** | `#6c757d` | Skip, disabled, inactive | + +--- + +## Swipe Response Colors + +Consistent across all swipe interactions: + +| Response | Color | Hex/RGBA | Icon | +|----------|-------|----------|------| +| **Yes** | Success | `#2ECC71` / `rgba(46, 204, 113, 0.4)` | Checkmark | +| **No** | Error | `#E74C3C` / `rgba(231, 76, 60, 0.4)` | X | +| **Maybe** | Warning | `#F39C12` / `rgba(243, 156, 18, 0.4)` | Question mark | +| **Skip** | Muted | `#6c757d` / `rgba(108, 117, 125, 0.4)` | Forward arrow | + +--- + +## Gradients + +### Brand Gradients + +``` +Primary: #e94560 → #9b59b6 (Rose to Purple) +Primary Subtle: rgba(233, 69, 96, 0.8) → rgba(155, 89, 182, 0.8) +Primary Reverse: #9b59b6 → #e94560 (Purple to Rose) +``` + +### Premium Gradients + +``` +Premium Gold: #D4AF37 → #B8860B +Premium Rose: #E8A4AE → #D4919B +``` + +### Background Gradients + +``` +Background: #1a1a2e → #0d0d1a (Light to Dark) +Background Reverse: #0d0d1a → #1a1a2e (Dark to Light) +``` + +### Boutique Card Gradients + +For pack cards and premium surfaces: + +``` +Boutique Rose: rgba(233, 69, 96, 0.4) → rgba(155, 89, 182, 0.4) +Boutique Purple: rgba(155, 89, 182, 0.4) → rgba(233, 69, 96, 0.4) +Boutique Dusty: rgba(183, 110, 121, 0.35) → rgba(139, 69, 87, 0.35) +Boutique Gold: rgba(212, 175, 55, 0.25) → rgba(184, 134, 11, 0.25) +Boutique Midnight: rgba(22, 33, 62, 0.8) → rgba(13, 13, 26, 0.8) +Boutique Amethyst: rgba(142, 68, 173, 0.4) → rgba(44, 62, 80, 0.4) +``` + +--- + +## Component Patterns + +### Buttons + +| Variant | Background | Text | Border | +|---------|------------|------|--------| +| **Primary** | Primary gradient | White | None | +| **Secondary** | Glass background | White | Glass border | +| **Ghost** | Transparent | Primary | None | +| **Danger** | Error gradient | White | None | +| **Feature** | Feature color gradient | White | None | + +### Cards + +| Variant | Background | Border | Use Case | +|---------|------------|--------|----------| +| **Glass** | Glass background | Glass border | Standard content | +| **Glass Elevated** | Glass background heavy | Glass border light | Emphasized content | +| **Solid** | Background light | Glass border | Non-blur contexts | + +### Inputs + +| State | Border Color | Background | +|-------|--------------|------------| +| **Default** | Glass border | Glass background | +| **Focused** | Primary | Glass background | +| **Error** | Error | Glass background | +| **Disabled** | Glass border | Glass background (opacity 0.5) | + +--- + +## Tab Bar + +| Element | Color | +|---------|-------| +| **Background** | `rgba(13, 13, 26, 0.92)` with blur | +| **Top Border** | `rgba(233, 69, 96, 0.2)` (primary tint) | +| **Active Icon** | Primary `#e94560` | +| **Inactive Icon** | Text secondary | +| **Badge** | Rose `#E8A4AE` | + +## Play Button (Center Tab) + +The main "play" button uses the primary brand gradient for maximum brand presence. + +| Element | Color | +|---------|-------| +| **Glow** | Primary glow `rgba(233, 69, 96, 0.3)` | +| **Border** | `rgba(233, 69, 96, 0.4)` | +| **Highlight** | Primary → Secondary gradient | +| **Icon** | Primary `#e94560` | + +## Radial Menu + +Feature-specific colors for quick action items: + +| Item | Variant | Color | +|------|---------|-------| +| **Match** | Primary | `#e94560` (core feature) | +| **Dares** | Gold | `#D4AF37` (premium/adventure) | +| **Quiz** | Rose | `#E8A4AE` (intimacy) | + +--- + +## Feature Icons (Locked) + +**IMPORTANT:** These icons are locked and must be used consistently across the entire app (radial menu, page headers, buttons, etc.). + +| Feature | Icon Name | Ionicons | Description | +|---------|-----------|----------|-------------| +| **Swipe/Match** | `flame` | 🔥 | Fire/passion - the core swiping experience | +| **Dares** | `flash` | ⚡ | Lightning bolt - adventure, excitement, energy | +| **Quiz** | `help-circle` | ❓ | Question mark - discovery, questions about each other | +| **Matches** (tab) | `heart` | ❤️ | Love heart - connections made between partners | +| **Profile** | `person` | 👤 | Person - user settings and profile | +| **Home** | `home` | 🏠 | Home - main dashboard | +| **Packs** | `layers` | 📚 | Layers - question pack collections | + +**Usage Rules:** +- Always use the exact icon name specified above +- Use the feature's designated color with its icon +- Never swap icons between features +- Radial menu, page headers, and any feature references must use the same icon + +--- + +## Spacing Scale + +| Token | Value | Usage | +|-------|-------|-------| +| `xs` | 4px | Tight spacing, icon gaps | +| `sm` | 8px | Small gaps, compact layouts | +| `md` | 16px | Standard spacing | +| `lg` | 24px | Section spacing | +| `xl` | 32px | Large gaps | +| `xxl` | 48px | Major sections | + +--- + +## Border Radius Scale + +| Token | Value | Usage | +|-------|-------|-------| +| `xs` | 4px | Small elements, tags | +| `sm` | 8px | Buttons, small cards | +| `md` | 12px | Standard cards | +| `lg` | 16px | Large cards | +| `xl` | 24px | Modal corners | +| `xxl` | 32px | Large containers | +| `full` | 9999px | Pills, circles | + +--- + +## Typography + +| Style | Size | Weight | Line Height | Usage | +|-------|------|--------|-------------|-------| +| `largeTitle` | 34px | Bold | 41px | Screen titles | +| `title1` | 28px | Bold | 34px | Section headers | +| `title2` | 22px | Bold | 28px | Card titles | +| `title3` | 20px | SemiBold | 25px | Subsection headers | +| `headline` | 17px | SemiBold | 22px | Emphasized body | +| `body` | 17px | Regular | 22px | Body text | +| `callout` | 16px | Regular | 21px | Secondary body | +| `subhead` | 15px | Regular | 20px | Supporting text | +| `footnote` | 13px | Regular | 18px | Captions | +| `caption1` | 12px | Regular | 16px | Small labels | +| `caption2` | 11px | Regular | 13px | Tiny text | + +--- + +## Shadow Scale + +| Style | Values | Usage | +|-------|--------|-------| +| `none` | — | Flat elements | +| `sm` | 0 2px 4px rgba(0,0,0,0.1) | Subtle lift | +| `md` | 0 4px 8px rgba(0,0,0,0.15) | Standard elevation | +| `lg` | 0 8px 16px rgba(0,0,0,0.2) | Prominent elevation | +| `xl` | 0 12px 24px rgba(0,0,0,0.25) | Maximum elevation | +| `glow` | 0 0 20px {color}Glow | Accent glow effect | + +--- + +## Platform Considerations + +### iOS +- Use BlurView for glass effects +- Haptic feedback on interactions +- Native-feeling animations + +### Android +- Fallback to solid semi-transparent backgrounds +- Reduced blur intensity for performance +- Adjusted shadow rendering + +--- + +## Do's and Don'ts + +### Do +- Use the primary gradient for main CTAs +- Apply feature colors consistently within a feature +- Use glass effects for depth and hierarchy +- Keep text high-contrast (white on dark) +- Use gold/rose for premium moments + +### Don't +- Mix feature colors within a single screen +- Use more than 2-3 accent colors per screen +- Apply the primary gradient to everything +- Use pure black (#000000) for backgrounds +- Forget to include the glow effect on premium elements + +--- + +## File Reference + +**Theme Source:** `apps/mobile/src/theme/index.ts` + +All colors, gradients, spacing, and typography are defined in the theme file. Always import from theme rather than hardcoding values. + +```typescript +import { theme } from '@/theme'; + +// Usage +const styles = { + backgroundColor: theme.colors.background, + color: theme.colors.premium.gold, +}; +``` diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index 6e94418..681ef1a 100755 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -44,13 +44,28 @@ interface RadialMenuItem { icon: keyof typeof Ionicons.glyphMap; label: string; angle: number; // degrees from top (0 = top, -45 = left, 45 = right) - color?: string; + variant: 'primary' | 'gold' | 'rose' | 'muted'; } +// Brand color palette for navigation +const NAV_COLORS = { + // Primary brand colors (from logo) + primary: colors.primary, + primaryRgba: 'rgba(233, 69, 96, ', + secondary: colors.secondary, + secondaryRgba: 'rgba(155, 89, 182, ', + // Feature-specific colors + gold: colors.premium.gold, + goldRgba: 'rgba(212, 175, 55, ', + rose: colors.premium.rose, + roseRgba: 'rgba(232, 164, 174, ', + dark: '#0d0d1a', +}; + const RADIAL_MENU_ITEMS: RadialMenuItem[] = [ - { id: 'dares', icon: 'flash', label: 'Dares', angle: -50, color: '#FF6B35' }, - { id: 'match', icon: 'flame', label: 'Match', angle: 0 }, - { id: 'quiz', icon: 'help-circle', label: 'Quiz', angle: 50, color: '#7B68EE' }, + { id: 'dares', icon: 'flash', label: 'Dares', angle: -50, variant: 'gold' }, + { id: 'match', icon: 'flame', label: 'Match', angle: 0, variant: 'primary' }, + { id: 'quiz', icon: 'help-circle', label: 'Quiz', angle: 50, variant: 'rose' }, ]; const RADIAL_DISTANCE = 100; // Distance from center button @@ -73,24 +88,26 @@ function PlayTabButton({ accessibilityState }: PlayTabButtonProps) { return ( - {/* Outer glow effect */} + {/* Outer glow effect - primary brand */} - {/* Main circle button */} + {/* Main circle button - premium dark with primary gradient border */} globalMenuToggle?.()}> - + + {/* Silk highlight at top - primary gradient */} + - + @@ -524,8 +541,8 @@ export default function AppLayout() { tabBarBackground: () => , tabBarStyle: shouldHideTabBar ? { display: 'none' } : { position: 'absolute', - backgroundColor: 'rgba(18, 18, 18, 0.85)', - borderTopColor: colors.glass.border, + backgroundColor: 'rgba(13, 13, 26, 0.92)', + borderTopColor: 'rgba(233, 69, 96, 0.2)', // primary brand color borderTopWidth: 1, paddingTop: 8, paddingBottom: Platform.OS === 'ios' ? 28 : 8, @@ -533,10 +550,11 @@ export default function AppLayout() { elevation: 0, }, tabBarActiveTintColor: colors.primary, - tabBarInactiveTintColor: '#fff', + tabBarInactiveTintColor: colors.textSecondary, tabBarLabelStyle: { - fontSize: 11, - fontWeight: "600", + fontSize: 10, + fontWeight: "500", + letterSpacing: 0.5, }, }} > @@ -574,9 +592,12 @@ export default function AppLayout() { ), tabBarBadge: (newMatchesCount + unreadCount) > 0 ? (newMatchesCount + unreadCount) : undefined, tabBarBadgeStyle: { - backgroundColor: colors.primary, - fontSize: 10, + backgroundColor: colors.premium.rose, + fontSize: 9, fontWeight: '600', + minWidth: 16, + height: 16, + borderRadius: 8, }, }} /> @@ -658,6 +679,43 @@ export default function AppLayout() { outputRange: [0, translateY], }); + // Brand and feature variant colors + const getVariantStyles = () => { + switch (item.variant) { + case 'primary': + return { + bg: `${NAV_COLORS.primaryRgba}0.15)`, + border: `${NAV_COLORS.primaryRgba}0.35)`, + icon: NAV_COLORS.primary, + label: NAV_COLORS.primary, + }; + case 'gold': + return { + bg: `${NAV_COLORS.goldRgba}0.15)`, + border: `${NAV_COLORS.goldRgba}0.35)`, + icon: NAV_COLORS.gold, + label: NAV_COLORS.gold, + }; + case 'rose': + return { + bg: `${NAV_COLORS.roseRgba}0.15)`, + border: `${NAV_COLORS.roseRgba}0.35)`, + icon: NAV_COLORS.rose, + label: NAV_COLORS.rose, + }; + case 'muted': + default: + return { + bg: 'rgba(255, 255, 255, 0.08)', + border: 'rgba(255, 255, 255, 0.15)', + icon: colors.textSecondary, + label: colors.textSecondary, + }; + } + }; + + const variantStyles = getVariantStyles(); + return ( - {item.label} + + {item.label} + ); @@ -890,7 +953,7 @@ const styles = StyleSheet.create({ color: colors.textSecondary, marginTop: 2, }, - // Play button styles + // Play button styles - Premium boutique playButtonContainer: { flex: 1, alignItems: 'center', @@ -899,23 +962,38 @@ const styles = StyleSheet.create({ }, playButtonGlow: { position: 'absolute', - width: 72, - height: 72, - borderRadius: 36, + width: 76, + height: 76, + borderRadius: 38, backgroundColor: colors.primaryGlow, - opacity: 0.5, + opacity: 0.4, }, playButtonGlowActive: { - opacity: 1, + opacity: 0.7, ...shadows.glow(colors.primary), }, playButtonCircle: { - width: 60, - height: 60, - borderRadius: 30, + width: 64, + height: 64, + borderRadius: 32, overflow: 'hidden', + borderWidth: 1.5, + borderColor: 'rgba(233, 69, 96, 0.4)', // primary brand ...shadows.lg, }, + playButtonInner: { + flex: 1, + backgroundColor: '#0d0d1a', + alignItems: 'center', + justifyContent: 'center', + }, + playButtonHighlight: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: '50%', + }, playButtonGradient: { flex: 1, alignItems: 'center', @@ -923,13 +1001,13 @@ const styles = StyleSheet.create({ }, }); -// Radial menu styles +// Radial menu styles - Premium boutique const TAB_BAR_HEIGHT = Platform.OS === 'ios' ? 88 : 64; const radialStyles = StyleSheet.create({ overlay: { ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0, 0, 0, 0.6)', + backgroundColor: 'rgba(13, 13, 26, 0.85)', zIndex: 998, }, menuContainer: { @@ -948,21 +1026,23 @@ const radialStyles = StyleSheet.create({ alignItems: 'center', }, menuItemCircle: { - width: 52, - height: 52, - borderRadius: 26, - backgroundColor: colors.primary, + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: 'rgba(22, 33, 62, 0.9)', justifyContent: 'center', alignItems: 'center', - ...shadows.lg, - borderWidth: 2, - borderColor: 'rgba(255, 255, 255, 0.2)', + ...shadows.md, + borderWidth: 1.5, + borderColor: 'rgba(255, 255, 255, 0.15)', }, menuItemLabel: { - ...typography.caption1, + ...typography.caption2, color: colors.text, - marginTop: 6, - fontWeight: '600', + marginTop: 8, + fontWeight: '500', + letterSpacing: 1, + textTransform: 'uppercase', textShadowColor: 'rgba(0, 0, 0, 0.5)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 2, diff --git a/apps/mobile/app/(app)/chat/[id].tsx b/apps/mobile/app/(app)/chat/[id].tsx index 3c85daa..bcb6087 100644 --- a/apps/mobile/app/(app)/chat/[id].tsx +++ b/apps/mobile/app/(app)/chat/[id].tsx @@ -1,25 +1,52 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Alert, Modal, Dimensions } from "react-native"; import { Image } from "expo-image"; +import { Video, ResizeMode, AVPlaybackStatus } from "expo-av"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Ionicons } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; -import Animated, { FadeIn, FadeInUp } from "react-native-reanimated"; +import Animated, { + FadeIn, + FadeInUp, + FadeInDown, + useSharedValue, + useAnimatedStyle, + withRepeat, + withSequence, + withTiming, + interpolate, + Easing, +} from "react-native-reanimated"; import { supabase } from "../../../src/lib/supabase"; import { useAuthStore, useMessageStore } from "../../../src/store"; import * as ImagePicker from "expo-image-picker"; import * as FileSystem from 'expo-file-system'; +import { Video as VideoCompressor } from 'react-native-compressor'; import { decode } from 'base64-arraybuffer'; import { Database } from "../../../src/types/supabase"; import { GradientBackground } from "../../../src/components/ui"; -import { colors, gradients, spacing, radius, typography } from "../../../src/theme"; +import { colors, gradients, spacing, radius, typography, shadows } from "../../../src/theme"; import { Events } from "../../../src/lib/analytics"; -import { getCachedSignedUrl, getStoragePath } from "../../../src/lib/imageCache"; +import { getCachedSignedUrl, getStoragePath, getVideoCachedUri, cacheVideo } from "../../../src/lib/imageCache"; type Message = Database["public"]["Tables"]["messages"]["Row"]; +// Upload status for skeleton display +type UploadStatus = { + mediaType: 'image' | 'video'; + status: 'compressing' | 'uploading'; + thumbnailUri?: string; +} | null; + +// Premium color palette for Chat (matches feature uses gold) +const ACCENT = colors.premium.gold; +const ACCENT_DARK = colors.premium.goldDark; +const ACCENT_RGBA = 'rgba(212, 175, 55, '; +const ROSE = colors.premium.rose; +const ROSE_RGBA = 'rgba(232, 164, 174, '; + export default function ChatScreen() { const { id } = useLocalSearchParams(); const matchId = id as string; @@ -31,13 +58,69 @@ export default function ChatScreen() { const [match, setMatch] = useState(null); const [inputText, setInputText] = useState(""); const [uploading, setUploading] = useState(false); + const [uploadStatus, setUploadStatus] = useState(null); const [partnerTyping, setPartnerTyping] = useState(false); const [fullScreenImage, setFullScreenImage] = useState(null); const [fullScreenImageLoading, setFullScreenImageLoading] = useState(true); + const [fullScreenVideo, setFullScreenVideo] = useState(null); const flatListRef = useRef(null); const typingTimeoutRef = useRef(null); const isFocusedRef = useRef(false); + // Ambient orb breathing animations + const orbBreathing1 = useSharedValue(0); + const orbBreathing2 = useSharedValue(0); + const orbDrift = useSharedValue(0); + + useEffect(() => { + // Primary orb breathing - 6 second cycle + orbBreathing1.value = withRepeat( + withSequence( + withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.sin) }), + withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.sin) }) + ), + -1, + true + ); + + // Secondary orb breathing - offset timing for variation + orbBreathing2.value = withRepeat( + withSequence( + withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) }), + withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }), + withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) }) + ), + -1, + true + ); + + // Subtle vertical drift - 8 second cycle + orbDrift.value = withRepeat( + withSequence( + withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }), + withTiming(0, { duration: 4000, easing: Easing.inOut(Easing.sin) }) + ), + -1, + true + ); + }, []); + + const orbStyle1 = useAnimatedStyle(() => ({ + opacity: interpolate(orbBreathing1.value, [0, 1], [0.2, 0.4]), + transform: [ + { translateY: interpolate(orbDrift.value, [0, 1], [0, -20]) }, + { scale: interpolate(orbBreathing1.value, [0, 1], [1, 1.1]) }, + ], + })); + + const orbStyle2 = useAnimatedStyle(() => ({ + opacity: interpolate(orbBreathing2.value, [0, 1], [0.15, 0.35]), + transform: [ + { translateY: interpolate(orbDrift.value, [0, 1], [20, 0]) }, + { scale: interpolate(orbBreathing2.value, [0, 1], [1, 1.1]) }, + ], + })); + // Track active chat to prevent notifications for current chat // Use useFocusEffect because screens don't unmount on navigation useFocusEffect( @@ -214,14 +297,17 @@ export default function ChatScreen() { } }; - const handlePickImage = async () => { + const handlePickMedia = async () => { const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, + mediaTypes: ImagePicker.MediaTypeOptions.All, quality: 0.7, + videoMaxDuration: 300, // 5 min max }); if (!result.canceled) { - uploadImage(result.assets[0].uri); + const asset = result.assets[0]; + const isVideo = asset.type === 'video'; + uploadMedia(asset.uri, isVideo ? 'video' : 'image'); } }; @@ -240,39 +326,96 @@ export default function ChatScreen() { }); if (!result.canceled) { - uploadImage(result.assets[0].uri); + uploadMedia(result.assets[0].uri, 'image'); } }; - const uploadImage = async (uri: string) => { + const handleRecordVideo = async () => { + const { status } = await ImagePicker.requestCameraPermissionsAsync(); + + if (status !== 'granted') { + Alert.alert( + "Camera Permission", + "Please allow camera access to record videos." + ); + return; + } + + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Videos, + quality: 0.7, + videoMaxDuration: 60, // 1 min for camera recording + }); + + if (!result.canceled) { + uploadMedia(result.assets[0].uri, 'video'); + } + }; + + const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => { if (!user) return; setUploading(true); + setUploadStatus({ mediaType, status: mediaType === 'video' ? 'compressing' : 'uploading', thumbnailUri: uri }); try { + let fileUri = uri; let fileBody; - let ext = "jpg"; + let ext = mediaType === 'video' ? 'mp4' : 'jpg'; + + // Compress videos on native platforms before upload + if (mediaType === 'video' && Platform.OS !== 'web') { + try { + console.log('Compressing video...'); + fileUri = await VideoCompressor.compress(uri, { + compressionMethod: 'auto', + maxSize: 720, // Max 720p resolution + minimumFileSizeForCompress: 0, // Always compress + }); + console.log('Video compressed successfully'); + ext = 'mp4'; // Compressed videos are always mp4 + // Update status to uploading after compression + setUploadStatus({ mediaType, status: 'uploading', thumbnailUri: uri }); + } catch (compressError) { + console.warn('Video compression failed, uploading original:', compressError); + // Fall back to original if compression fails + fileUri = uri; + setUploadStatus({ mediaType, status: 'uploading', thumbnailUri: uri }); + } + } if (Platform.OS === 'web') { - const response = await fetch(uri); + const response = await fetch(fileUri); const blob = await response.blob(); fileBody = blob; - if (blob.type === 'image/png') ext = 'png'; - else if (blob.type === 'image/jpeg' || blob.type === 'image/jpg') ext = 'jpg'; - else if (blob.type === 'image/gif') ext = 'gif'; - else if (blob.type === 'image/webp') ext = 'webp'; + // Detect extension from blob type + if (mediaType === 'video') { + if (blob.type === 'video/mp4') ext = 'mp4'; + else if (blob.type === 'video/quicktime') ext = 'mov'; + else if (blob.type === 'video/webm') ext = 'webm'; + } else { + if (blob.type === 'image/png') ext = 'png'; + else if (blob.type === 'image/jpeg' || blob.type === 'image/jpg') ext = 'jpg'; + else if (blob.type === 'image/gif') ext = 'gif'; + else if (blob.type === 'image/webp') ext = 'webp'; + } } else { - const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 }); + const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 }); fileBody = decode(base64); - const uriExt = uri.split('.').pop(); - if (uriExt && uriExt !== uri) { - ext = uriExt.toLowerCase(); + if (mediaType !== 'video') { + // Only use URI extension for non-videos (videos are always mp4 after compression) + const uriExt = fileUri.split('.').pop(); + if (uriExt && uriExt !== fileUri) { + ext = uriExt.toLowerCase(); + } } } const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`; - const contentType = `image/${ext === "jpg" ? "jpeg" : ext}`; + const contentType = mediaType === 'video' + ? `video/${ext === 'mov' ? 'quicktime' : ext}` + : `image/${ext === 'jpg' ? 'jpeg' : ext}`; const { error: uploadError } = await supabase.storage .from("chat-media") @@ -288,25 +431,41 @@ export default function ChatScreen() { match_id: matchId, user_id: user.id, media_path: fileName, + media_type: mediaType, }); Events.mediaUploaded(); } catch (error) { - Alert.alert("Error", "Failed to upload image"); + Alert.alert("Error", `Failed to upload ${mediaType}`); console.error(error); } finally { setUploading(false); + setUploadStatus(null); } }; const revealMessage = async (messageId: string) => { + const message = messages.find(m => m.id === messageId); + const now = new Date(); + // Set expiration 30 days from now for videos only + const expiresAt = message?.media_type === 'video' + ? new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString() + : null; + setMessages(prev => prev.map(m => - m.id === messageId ? { ...m, media_viewed_at: new Date().toISOString() } : m + m.id === messageId ? { + ...m, + media_viewed_at: now.toISOString(), + media_expires_at: expiresAt + } : m )); await supabase .from("messages") - .update({ media_viewed_at: new Date().toISOString() }) + .update({ + media_viewed_at: now.toISOString(), + media_expires_at: expiresAt + }) .eq("id", messageId); }; @@ -318,17 +477,42 @@ export default function ChatScreen() { style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]} > {isMe ? ( - - - + + + {/* Silk highlight */} + + + + ) : ( - - + + + {/* Subtle gradient background */} + + {/* Silk highlight */} + + + )} @@ -339,28 +523,71 @@ export default function ChatScreen() { return ( + {/* Ambient Orbs - Gold/Rose for matches feature */} + + + + + + + - {/* Custom Header */} - + {/* Premium Header */} + + {/* Gradient background */} + + {/* Top silk highlight */} + + router.back()} style={styles.backButton} activeOpacity={0.7} > - + + + + {partner?.avatar_url ? ( - + + + {/* Avatar ring */} + + ) : ( )} - {partner?.name || "Chat"} - - - - - {/* Question Header */} - {match?.question && ( - - - - - - - {match.question.text} - {match.question.partner_text ? ` / ${match.question.partner_text}` : ""} - - {match.responses && match.responses.length > 0 && ( - - {match.responses.map((response: any) => ( - - - {response.user_id === user?.id ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')} - - - {response.answer} - - ))} - - )} + + CONVERSATION WITH + {partner?.name || "Partner"} + + + + {/* Bottom border */} + + + + {/* Premium Match Card */} + {match?.question && ( + + {/* Card gradient background */} + + + {/* Top glow for YES+YES */} + {isYesYes && ( + + )} + + {/* Match Label */} + + {isYesYes ? "PERFECT MATCH" : "SOFT MATCH"} + + + {/* Decorative Separator */} + + + + + + + {/* Question Text */} + + "{match.question.text}" + + {match.question.partner_text && ( + + "{match.question.partner_text}" + + )} + + {/* Response Pills */} + {match.responses && match.responses.length > 0 && ( + + {match.responses.map((response: any) => { + const isUser = response.user_id === user?.id; + const isYes = response.answer === 'yes'; + return ( + + {isYes ? ( + + + + {isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')} + + + ) : ( + + + + {isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')} + + + )} + + ); + })} + + )} + + {/* Premium border */} + + )} {/* Messages List */} @@ -431,47 +718,84 @@ export default function ChatScreen() { keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} ListHeaderComponent={ - partnerTyping ? ( - - - - - - + <> + {/* Show uploading skeleton when media is being uploaded */} + {uploadStatus && } + + {/* Show typing indicator when partner is typing */} + {partnerTyping && ( + + + {/* Subtle gradient */} + + + + + + - - - ) : null + + )} + } ListEmptyComponent={ - - - - - Start the conversation! - + + {/* Premium icon */} + + + + + {/* Decorative separator */} + + + + + + + Start the Conversation + + Share your thoughts about this match + + } /> - {/* Input Bar */} - + {/* Premium Input Bar */} + + {/* Gradient background */} + + {/* Top border glow */} + + - + {/* Full Screen Image Modal */} @@ -487,10 +811,17 @@ export default function ChatScreen() { onPress={() => setFullScreenImage(null)} activeOpacity={0.8} > - + + + {fullScreenImageLoading && ( - + )} {fullScreenImage && ( + + {/* Full Screen Video Modal */} + setFullScreenVideo(null)} + > + + setFullScreenVideo(null)} + activeOpacity={0.8} + > + + + + + {fullScreenVideo && ( + + ); } +// Animated typing dot component +function TypingDot({ delay }: { delay: number }) { + const opacity = useSharedValue(0.4); + + useEffect(() => { + opacity.value = withRepeat( + withSequence( + withTiming(1, { duration: 300 }), + withTiming(0.4, { duration: 300 }) + ), + -1, + true + ); + }, []); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); + + return ( + 0 ? 4 : 0 }]}> + + + ); +} + +// Uploading skeleton component +function UploadingSkeleton({ uploadStatus }: { uploadStatus: UploadStatus }) { + const shimmer = useSharedValue(0); + + useEffect(() => { + shimmer.value = withRepeat( + withTiming(1, { duration: 1500, easing: Easing.inOut(Easing.ease) }), + -1, + false + ); + }, []); + + const shimmerStyle = useAnimatedStyle(() => ({ + opacity: interpolate(shimmer.value, [0, 0.5, 1], [0.3, 0.6, 0.3]), + })); + + if (!uploadStatus) return null; + + const isVideo = uploadStatus.mediaType === 'video'; + const statusText = uploadStatus.status === 'compressing' + ? 'Compressing video...' + : `Uploading ${isVideo ? 'video' : 'image'}...`; + + return ( + + + + {/* Silk highlight */} + + + {/* Skeleton media placeholder */} + + {uploadStatus.thumbnailUri && !isVideo ? ( + + ) : ( + + + + )} + + {/* Overlay with spinner and status */} + + + {statusText} + + + + + + ); +} + +// Video player component with native-like tap behavior +function ChatVideoPlayer({ + signedUrl, + storagePath, + urlError, + onFullScreen, +}: { + signedUrl: string | null; + storagePath: string; + urlError: boolean; + onFullScreen: (uri: string) => void; +}) { + const videoRef = useRef ))} @@ -281,40 +277,48 @@ export default function HomeScreen() { entering={FadeInDown.delay(400).duration(500)} style={styles.section} > - Getting Started - + + + + + + Getting Started + + + + - + Enable question packs - Choose topics you want to explore + Choose topics to explore together - - + + - + Swipe on questions Answer yes, no, or maybe - - + + - + Discover matches See where you both agree - + )} @@ -346,27 +350,51 @@ const styles = StyleSheet.create({ header: { paddingTop: 60, paddingHorizontal: spacing.lg, - paddingBottom: spacing.lg, - flexDirection: 'row', - justifyContent: 'space-between', + paddingBottom: spacing.xl, alignItems: 'center', }, - headerLeft: { - flex: 1, + label: { + ...typography.caption2, + fontWeight: '600', + letterSpacing: 2.5, + color: ACCENT_COLOR, + textAlign: 'center', + marginBottom: spacing.xs, }, greeting: { - ...typography.title1, + ...typography.largeTitle, color: colors.text, + textAlign: 'center', + }, + separator: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginVertical: spacing.md, + width: 120, + }, + separatorLine: { + flex: 1, + height: 1, + backgroundColor: 'rgba(233, 69, 96, 0.3)', + }, + separatorDiamond: { + width: 5, + height: 5, + backgroundColor: ACCENT_COLOR, + transform: [{ rotate: '45deg' }], + marginHorizontal: spacing.sm, + opacity: 0.6, }, partnerBadge: { flexDirection: 'row', alignItems: 'center', - backgroundColor: colors.primaryLight, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, + backgroundColor: 'rgba(233, 69, 96, 0.1)', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, borderRadius: radius.full, - alignSelf: 'flex-start', - marginTop: spacing.sm, + borderWidth: 1, + borderColor: 'rgba(233, 69, 96, 0.2)', gap: spacing.xs, }, partnerText: { @@ -378,11 +406,11 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', backgroundColor: colors.glass.background, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, borderRadius: radius.full, - alignSelf: 'flex-start', - marginTop: spacing.sm, + borderWidth: 1, + borderColor: colors.glass.border, gap: spacing.xs, }, pairText: { @@ -392,12 +420,12 @@ const styles = StyleSheet.create({ waitingBadge: { flexDirection: 'row', alignItems: 'center', - backgroundColor: colors.primaryLight, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, + backgroundColor: 'rgba(233, 69, 96, 0.1)', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, borderRadius: radius.full, - alignSelf: 'flex-start', - marginTop: spacing.sm, + borderWidth: 1, + borderColor: 'rgba(233, 69, 96, 0.2)', gap: spacing.xs, }, waitingText: { @@ -405,26 +433,6 @@ const styles = StyleSheet.create({ color: colors.text, fontWeight: '500', }, - avatarGradient: { - width: 48, - height: 48, - borderRadius: 24, - justifyContent: 'center', - alignItems: 'center', - ...shadows.md, - }, - avatarInner: { - width: 42, - height: 42, - borderRadius: 21, - backgroundColor: colors.background, - justifyContent: 'center', - alignItems: 'center', - }, - avatarText: { - ...typography.headline, - color: colors.text, - }, statsContainer: { flexDirection: "row", paddingHorizontal: spacing.lg, @@ -433,18 +441,30 @@ const styles = StyleSheet.create({ statCardWrapper: { flex: 1, }, - statContent: { + statCard: { alignItems: "center", - paddingVertical: spacing.sm, + paddingVertical: spacing.md, + backgroundColor: 'rgba(233, 69, 96, 0.05)', + borderRadius: radius.lg, + borderWidth: 1, + borderColor: 'rgba(233, 69, 96, 0.1)', + }, + statCardActive: { + backgroundColor: 'rgba(233, 69, 96, 0.1)', + borderColor: 'rgba(233, 69, 96, 0.2)', }, statIcon: { width: 36, height: 36, borderRadius: 18, + backgroundColor: 'rgba(233, 69, 96, 0.1)', justifyContent: "center", alignItems: "center", marginBottom: spacing.sm, }, + statIconActive: { + backgroundColor: ACCENT_COLOR, + }, statNumber: { ...typography.title2, color: colors.text, @@ -456,9 +476,23 @@ const styles = StyleSheet.create({ }, ctaSection: { paddingHorizontal: spacing.lg, - marginTop: spacing.lg, + marginTop: spacing.xl, }, ctaCard: { + position: 'relative', + borderRadius: radius.xl, + overflow: 'hidden', + }, + ctaGlow: { + position: 'absolute', + top: -20, + left: -20, + right: -20, + bottom: -20, + backgroundColor: ACCENT_COLOR, + borderRadius: radius.xl + 20, + }, + ctaGradient: { borderRadius: radius.xl, padding: spacing.lg, flexDirection: "row", @@ -468,21 +502,29 @@ const styles = StyleSheet.create({ ctaContent: { flex: 1, }, + ctaLabel: { + ...typography.caption2, + fontWeight: '600', + letterSpacing: 2, + color: 'rgba(255,255,255,0.8)', + marginBottom: spacing.xs, + }, ctaTitle: { - ...typography.title3, + ...typography.title2, color: colors.text, marginBottom: spacing.xs, }, ctaSubtitle: { ...typography.subhead, color: "rgba(255,255,255,0.8)", - lineHeight: 20, }, ctaIconContainer: { - width: 48, - height: 48, - borderRadius: 24, + width: 56, + height: 56, + borderRadius: 28, backgroundColor: "rgba(255,255,255,0.2)", + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.3)', justifyContent: "center", alignItems: "center", marginLeft: spacing.md, @@ -497,6 +539,19 @@ const styles = StyleSheet.create({ alignItems: "center", marginBottom: spacing.md, }, + sectionTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + sectionIcon: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(233, 69, 96, 0.1)', + justifyContent: 'center', + alignItems: 'center', + }, sectionTitle: { ...typography.headline, color: colors.text, @@ -508,19 +563,24 @@ const styles = StyleSheet.create({ }, seeAllText: { ...typography.subhead, - color: colors.primary, + color: ACCENT_COLOR, }, matchCard: { marginBottom: spacing.sm, + backgroundColor: 'rgba(233, 69, 96, 0.05)', + borderRadius: radius.md, + borderWidth: 1, + borderColor: 'rgba(233, 69, 96, 0.1)', + padding: spacing.md, }, matchRow: { flexDirection: "row", alignItems: "center", }, matchIcon: { - width: 32, - height: 32, - borderRadius: 16, + width: 28, + height: 28, + borderRadius: 14, justifyContent: "center", alignItems: "center", marginRight: spacing.md, @@ -534,17 +594,16 @@ const styles = StyleSheet.create({ width: 8, height: 8, borderRadius: 4, - backgroundColor: colors.primary, - }, - tipsTitle: { - ...typography.caption1, - color: colors.textTertiary, - textTransform: 'uppercase', - letterSpacing: 1.5, - marginBottom: spacing.sm, - marginLeft: spacing.xs, + backgroundColor: ACCENT_COLOR, }, tipCard: { + backgroundColor: 'rgba(233, 69, 96, 0.05)', + borderRadius: radius.md, + borderWidth: 1, + borderColor: 'rgba(233, 69, 96, 0.1)', + padding: spacing.md, + }, + tipCardSpaced: { marginTop: spacing.sm, }, tipRow: { @@ -555,7 +614,7 @@ const styles = StyleSheet.create({ width: 36, height: 36, borderRadius: 18, - backgroundColor: colors.primaryLight, + backgroundColor: 'rgba(233, 69, 96, 0.1)', justifyContent: 'center', alignItems: 'center', marginRight: spacing.md, diff --git a/apps/mobile/app/(app)/matches.tsx b/apps/mobile/app/(app)/matches.tsx index 5d95325..9e5dc26 100755 --- a/apps/mobile/app/(app)/matches.tsx +++ b/apps/mobile/app/(app)/matches.tsx @@ -5,12 +5,30 @@ import { useFocusEffect } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { useRouter } from "expo-router"; import { LinearGradient } from "expo-linear-gradient"; -import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated"; -import { GradientBackground, GlassCard } from "../../src/components/ui"; +import Animated, { + FadeIn, + FadeInDown, + FadeInRight, + FadeInUp, + useSharedValue, + useAnimatedStyle, + withRepeat, + withSequence, + withTiming, + interpolate, + Easing, +} from "react-native-reanimated"; +import { GradientBackground, GlassCard, GlassButton } 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"; +// Premium color palette +const ACCENT = colors.premium.gold; +const ACCENT_RGBA = 'rgba(212, 175, 55, '; +const ROSE = colors.premium.rose; +const ROSE_RGBA = 'rgba(232, 164, 174, '; + const MAX_CONTENT_WIDTH = 500; export default function MatchesScreen() { @@ -21,6 +39,60 @@ export default function MatchesScreen() { const isWideScreen = width > MAX_CONTENT_WIDTH; const [showTutorial, setShowTutorial] = useState(false); + // Ambient orb breathing animations + const orbBreathing1 = useSharedValue(0); + const orbBreathing2 = useSharedValue(0); + const orbDrift = useSharedValue(0); + + useEffect(() => { + // Primary orb breathing - 6 second cycle + orbBreathing1.value = withRepeat( + withSequence( + withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.sin) }), + withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.sin) }) + ), + -1, + true + ); + + // Secondary orb breathing - offset timing for variation + orbBreathing2.value = withRepeat( + withSequence( + withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) }), + withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }), + withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) }) + ), + -1, + true + ); + + // Subtle vertical drift - 8 second cycle + orbDrift.value = withRepeat( + withSequence( + withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }), + withTiming(0, { duration: 4000, easing: Easing.inOut(Easing.sin) }) + ), + -1, + true + ); + }, []); + + const orbStyle1 = useAnimatedStyle(() => ({ + opacity: interpolate(orbBreathing1.value, [0, 1], [0.25, 0.5]), + transform: [ + { translateY: interpolate(orbDrift.value, [0, 1], [0, -20]) }, + { scale: interpolate(orbBreathing1.value, [0, 1], [1, 1.1]) }, + ], + })); + + const orbStyle2 = useAnimatedStyle(() => ({ + opacity: interpolate(orbBreathing2.value, [0, 1], [0.2, 0.4]), + transform: [ + { translateY: interpolate(orbDrift.value, [0, 1], [20, 0]) }, + { scale: interpolate(orbBreathing2.value, [0, 1], [1, 1.1]) }, + ], + })); + // Check if tutorial should be shown when screen is focused or matches change useFocusEffect( useCallback(() => { @@ -78,52 +150,77 @@ export default function MatchesScreen() { onPress={() => router.push(`/chat/${item.id}`)} activeOpacity={0.8} > - + + {/* Subtle gradient background */} + + {/* Top silk highlight */} + + - + {/* Premium icon container */} + - + + - + {userText} {item.question.partner_text && ( - + Partner: {partnerText} )} - - + + {isYesYes ? "YES + YES" : "YES + MAYBE"} - + {new Date(item.created_at).toLocaleDateString()} + {item.unreadCount > 0 && ( - - + + )} - - + + - + + {/* Premium border */} + + ); @@ -141,21 +238,48 @@ export default function MatchesScreen() { return ( + {/* Ambient Orbs - Premium gold/rose */} + + + + + + + + {/* Premium Header */} - - Matches - - - {matches.length} + + {/* Premium label */} + DISCOVER + Matches + + {/* Decorative separator */} + + + + + + + {/* Count badge */} + + + {matches.length} {matches.length === 1 ? 'MATCH' : 'MATCHES'} - - Discover what you both enjoy together - - + + + + {/* Title section */} + BEGIN + No Matches Yet + + {/* Decorative separator */} + + + + + + + {/* Status badge */} + - - - - - No matches yet - - Keep swiping to find things you both enjoy! + START EXPLORING + + + {/* Description */} + + Answer questions together to discover what you both enjoy. Matches appear when you agree! - + + + Swipe right for yes + + + + Match when you both agree + + + + Chat about your matches + + + + {/* Bottom teaser */} + Your first match is just a swipe away + + router.push("/(app)/swipe")} - activeOpacity={0.8} + style={{ marginTop: spacing.lg }} > - - - Start Swiping - - + Start Swiping + } /> @@ -228,47 +377,93 @@ const styles = StyleSheet.create({ justifyContent: "center", alignItems: "center", }, + // Ambient orbs + ambientOrb: { + position: 'absolute', + width: 300, + height: 300, + borderRadius: 150, + }, + orbTopRight: { + top: 60, + right: -40, + }, + orbBottomLeft: { + bottom: 180, + left: -40, + }, + orbGradient: { + width: '100%', + height: '100%', + borderRadius: 150, + }, + // Premium Header header: { paddingTop: 60, paddingHorizontal: spacing.lg, - paddingBottom: spacing.lg, + paddingBottom: spacing.md, }, headerWide: { alignSelf: 'center', width: '100%', maxWidth: MAX_CONTENT_WIDTH, }, - headerTop: { - flexDirection: 'row', + headerContent: { alignItems: 'center', - justifyContent: 'space-between', }, - title: { - ...typography.title1, + headerLabel: { + ...typography.caption2, + fontWeight: '600', + letterSpacing: 3, + color: ACCENT, + marginBottom: spacing.xs, + }, + headerTitle: { + ...typography.largeTitle, color: colors.text, + textAlign: 'center', }, - countBadge: { + headerSeparator: { flexDirection: 'row', alignItems: 'center', - backgroundColor: colors.primaryLight, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, + justifyContent: 'center', + marginVertical: spacing.md, + width: 100, + }, + headerSeparatorLine: { + flex: 1, + height: 1, + backgroundColor: `${ACCENT_RGBA}0.3)`, + }, + headerSeparatorDiamond: { + width: 6, + height: 6, + backgroundColor: ACCENT, + transform: [{ rotate: '45deg' }], + marginHorizontal: spacing.sm, + opacity: 0.6, + }, + countBadgePremium: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: `${ACCENT_RGBA}0.1)`, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, borderRadius: radius.full, + borderWidth: 1, + borderColor: `${ACCENT_RGBA}0.2)`, gap: spacing.xs, }, - countText: { - ...typography.subhead, - color: colors.text, + countTextPremium: { + ...typography.caption2, fontWeight: '600', + letterSpacing: 2, + color: ACCENT, }, - subtitle: { - ...typography.subhead, - color: colors.textSecondary, - marginTop: spacing.xs, - }, + // List list: { padding: spacing.lg, - paddingTop: 0, + paddingTop: spacing.sm, paddingBottom: Platform.OS === 'ios' ? 120 : 100, }, listWide: { @@ -276,32 +471,60 @@ const styles = StyleSheet.create({ width: '100%', maxWidth: MAX_CONTENT_WIDTH, }, - matchCard: { + // Premium Match Card + matchCardPremium: { marginBottom: spacing.sm, + borderRadius: radius.lg, + overflow: 'hidden', + padding: spacing.md, + }, + cardSilkHighlight: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 60, + }, + cardPremiumBorder: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: `${ACCENT_RGBA}0.15)`, }, matchRow: { flexDirection: "row", alignItems: "center", }, - iconContainer: { - width: 48, - height: 48, - borderRadius: 24, + iconContainerPremium: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: `${ROSE_RGBA}0.1)`, + borderWidth: 1, + borderColor: `${ROSE_RGBA}0.2)`, justifyContent: "center", alignItems: "center", marginRight: spacing.md, }, + iconContainerYesYes: { + backgroundColor: `${ACCENT_RGBA}0.1)`, + borderColor: `${ACCENT_RGBA}0.2)`, + }, content: { flex: 1, }, - questionText: { - ...typography.body, + questionTextPremium: { + ...typography.subhead, color: colors.text, fontWeight: "600", marginBottom: spacing.xs, - lineHeight: 22, + lineHeight: 20, }, - partnerText: { + partnerTextPremium: { ...typography.caption1, color: colors.textSecondary, fontStyle: "italic", @@ -312,27 +535,31 @@ const styles = StyleSheet.create({ alignItems: "center", justifyContent: "space-between", }, - tag: { - backgroundColor: colors.glass.background, + tagPremium: { + backgroundColor: `${ROSE_RGBA}0.1)`, paddingHorizontal: spacing.sm, - paddingVertical: 4, + paddingVertical: 3, borderRadius: radius.sm, + borderWidth: 1, + borderColor: `${ROSE_RGBA}0.15)`, }, - tagHighlight: { - backgroundColor: colors.primaryLight, + tagPremiumHighlight: { + backgroundColor: `${ACCENT_RGBA}0.1)`, + borderColor: `${ACCENT_RGBA}0.2)`, }, - tagText: { + tagTextPremium: { ...typography.caption2, - color: colors.textSecondary, - fontWeight: "700", - letterSpacing: 0.5, + color: ROSE, + fontWeight: "600", + letterSpacing: 1, }, - tagTextHighlight: { - color: colors.primary, + tagTextPremiumHighlight: { + color: ACCENT, }, - date: { + datePremium: { ...typography.caption2, color: colors.textTertiary, + fontStyle: 'italic', }, rightSection: { flexDirection: 'row', @@ -340,70 +567,115 @@ const styles = StyleSheet.create({ gap: spacing.sm, marginLeft: spacing.sm, }, - unreadBadge: { - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: colors.primary, + unreadBadgePremium: { + width: 22, + height: 22, + borderRadius: 11, + backgroundColor: ACCENT, justifyContent: 'center', alignItems: 'center', }, - chevronContainer: { + chevronContainerPremium: { width: 28, height: 28, borderRadius: 14, - backgroundColor: colors.glass.background, + backgroundColor: `${ACCENT_RGBA}0.08)`, + borderWidth: 1, + borderColor: `${ACCENT_RGBA}0.15)`, justifyContent: 'center', alignItems: 'center', }, - emptyContainer: { - alignItems: "center", - paddingVertical: spacing.xxl, - paddingHorizontal: spacing.lg, + // Premium Empty State + emptyContent: { + width: '100%', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.xl, }, - emptyIconGradient: { - width: 100, - height: 100, - borderRadius: 50, + emptyIconContainerPremium: { + width: 72, + height: 72, + borderRadius: 36, + backgroundColor: `${ROSE_RGBA}0.1)`, + borderWidth: 1, + borderColor: `${ROSE_RGBA}0.2)`, justifyContent: 'center', alignItems: 'center', - marginBottom: spacing.lg, - ...shadows.lg, - }, - emptyIconInner: { - width: 88, - height: 88, - borderRadius: 44, - backgroundColor: colors.background, - justifyContent: 'center', - alignItems: 'center', - }, - emptyTitle: { - ...typography.title2, - color: colors.text, - marginBottom: spacing.sm, - }, - emptyText: { - ...typography.body, - color: colors.textSecondary, - textAlign: "center", marginBottom: spacing.xl, }, - emptyButton: { - borderRadius: radius.full, - overflow: 'hidden', - ...shadows.md, + emptyLabel: { + ...typography.caption1, + fontWeight: '600', + letterSpacing: 3, + color: ROSE, + textAlign: 'center', + marginBottom: spacing.xs, }, - emptyButtonGradient: { + emptyTitlePremium: { + ...typography.largeTitle, + color: colors.text, + textAlign: 'center', + }, + emptySeparator: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: spacing.lg, - paddingVertical: spacing.md, - gap: spacing.sm, + justifyContent: 'center', + marginVertical: spacing.lg, + width: 140, }, - emptyButtonText: { - ...typography.body, - color: colors.text, + emptySeparatorLine: { + flex: 1, + height: 1, + backgroundColor: `${ROSE_RGBA}0.3)`, + }, + emptySeparatorDiamond: { + width: 6, + height: 6, + backgroundColor: ROSE, + transform: [{ rotate: '45deg' }], + marginHorizontal: spacing.md, + opacity: 0.6, + }, + emptyBadge: { + backgroundColor: `${ROSE_RGBA}0.1)`, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radius.full, + borderWidth: 1, + borderColor: `${ROSE_RGBA}0.2)`, + marginBottom: spacing.xl, + }, + emptyBadgeText: { + ...typography.caption2, fontWeight: '600', + letterSpacing: 2, + color: ROSE, + }, + emptyDescription: { + ...typography.body, + color: colors.textSecondary, + textAlign: 'center', + marginBottom: spacing.xl, + paddingHorizontal: spacing.md, + }, + emptyFeatures: { + marginBottom: spacing.xl, + }, + emptyFeatureItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.sm, + }, + emptyFeatureText: { + ...typography.subhead, + color: colors.text, + marginLeft: spacing.sm, + }, + emptyTeaser: { + ...typography.footnote, + fontStyle: 'italic', + color: colors.textTertiary, + textAlign: 'center', }, }); diff --git a/apps/mobile/app/(app)/onboarding.tsx b/apps/mobile/app/(app)/onboarding.tsx index 9b3439e..5a1fe02 100644 --- a/apps/mobile/app/(app)/onboarding.tsx +++ b/apps/mobile/app/(app)/onboarding.tsx @@ -605,7 +605,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingTop: Platform.OS === 'ios' ? 60 : 40, + paddingTop: 60, paddingBottom: spacing.md, paddingHorizontal: spacing.md, }, diff --git a/apps/mobile/app/(app)/packs.tsx b/apps/mobile/app/(app)/packs.tsx deleted file mode 100755 index 082b2e2..0000000 --- a/apps/mobile/app/(app)/packs.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { View, Text, StyleSheet, ScrollView, RefreshControl, Platform, Alert } from "react-native"; -import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../src/store"; -import { Ionicons } from "@expo/vector-icons"; -import { router, useLocalSearchParams } from "expo-router"; -import { useEffect, useMemo, useState, useCallback } from "react"; -import Animated, { FadeInDown, FadeIn, Layout } from "react-native-reanimated"; -import { GradientBackground } from "../../src/components/ui"; -import { Paywall } from "../../src/components/Paywall"; -import { PackTeaser } from "../../src/components/PackTeaser"; -import { CategoryTabs, FilterType } from "../../src/components/CategoryTabs"; -import { BoutiquePackCard } from "../../src/components/BoutiquePackCard"; -import { colors, spacing, typography } from "../../src/theme"; -import type { QuestionPack } from "../../src/types"; - -export default function PacksScreen() { - const { category } = useLocalSearchParams<{ category?: string }>(); - const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore(); - const { user, partner } = useAuthStore(); - const { subscription } = useSubscriptionStore(); - const [selectedFilter, setSelectedFilter] = useState('all'); - const [showPaywall, setShowPaywall] = useState(false); - const [showTeaser, setShowTeaser] = useState(false); - const [teaserPack, setTeaserPack] = useState(null); - - // Check if user or partner has premium access - const hasPremiumAccess = useMemo(() => { - return user?.is_premium || partner?.is_premium || subscription.isProUser; - }, [user?.is_premium, partner?.is_premium, subscription.isProUser]); - - useEffect(() => { - 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) { - setTeaserPack(pack); - setShowTeaser(true); - return; - } - const result = await togglePack(pack.id); - if (!result.success && result.reason === "no_couple") { - Alert.alert( - "Pair with Partner", - "You need to pair with your partner before you can customise question packs.", - [ - { text: "Later", style: "cancel" }, - { text: "Pair Now", onPress: () => router.push("/pairing") } - ] - ); - } - }, [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); - setShowPaywall(true); - }, []); - - // Handle teaser close - const handleTeaserClose = useCallback(() => { - setShowTeaser(false); - setTeaserPack(null); - }, []); - - const enabledPacks = packs.filter(p => enabledPackIds.includes(p.id)); - const enabledPackCount = enabledPacks.length; - const totalQuestions = enabledPacks.reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0); - - return ( - - - {/* Header */} - - Question Packs - - {totalQuestions} {totalQuestions === 1 ? 'question' : 'questions'} from {enabledPackCount} {enabledPackCount === 1 ? 'pack' : 'packs'} enabled - - - - {/* Category Tabs */} - - - - - {/* Masonry Grid */} - - } - contentContainerStyle={styles.scrollContent} - > - {filteredPacks.length === 0 ? ( - - - - - No packs available - - {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 && ( - - )} - - setShowPaywall(false)} - onSuccess={() => { - // Refresh packs after successful purchase - fetchPacks(); - }} - /> - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - paddingTop: 60, - paddingHorizontal: spacing.lg, - paddingBottom: spacing.sm, - }, - title: { - ...typography.title1, - color: colors.text, - }, - subtitle: { - ...typography.subhead, - color: colors.textSecondary, - marginTop: spacing.xs, - }, - scrollContent: { - paddingBottom: Platform.OS === 'ios' ? 120 : 100, - }, - masonryContainer: { - flexDirection: 'row', - paddingHorizontal: spacing.md, - gap: spacing.md, - }, - column: { - flex: 1, - gap: spacing.md, - }, - emptyContainer: { - alignItems: "center", - paddingVertical: spacing.xxl, - paddingHorizontal: spacing.lg, - }, - emptyIconContainer: { - width: 80, - height: 80, - borderRadius: 40, - backgroundColor: colors.glass.background, - justifyContent: "center", - alignItems: "center", - marginBottom: spacing.md, - }, - emptyTitle: { - ...typography.title3, - color: colors.text, - marginBottom: spacing.xs, - }, - emptyText: { - ...typography.body, - color: colors.textSecondary, - textAlign: "center", - }, -}); diff --git a/apps/mobile/app/(app)/packs/_layout.tsx b/apps/mobile/app/(app)/packs/_layout.tsx new file mode 100644 index 0000000..8a6bdeb --- /dev/null +++ b/apps/mobile/app/(app)/packs/_layout.tsx @@ -0,0 +1,15 @@ +import { Stack } from "expo-router"; +import { colors } from "../../../src/theme"; + +export default function PacksLayout() { + return ( + + ); +} diff --git a/apps/mobile/app/(app)/packs/index.tsx b/apps/mobile/app/(app)/packs/index.tsx new file mode 100644 index 0000000..fb6fda9 --- /dev/null +++ b/apps/mobile/app/(app)/packs/index.tsx @@ -0,0 +1,297 @@ +import { View, Text, StyleSheet, Pressable, Platform } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { router } from "expo-router"; +import { useEffect } from "react"; +import Animated, { FadeInDown } from "react-native-reanimated"; +import { LinearGradient } from "expo-linear-gradient"; +import { GradientBackground } from "../../../src/components/ui"; +import { usePacksStore } from "../../../src/store"; +import { colors, spacing, radius, typography, shadows } from "../../../src/theme"; + +interface PackTypeCard { + id: string; + title: string; + description: string; + icon: keyof typeof Ionicons.glyphMap; + color: string; + colorRgba: string; + gradient: [string, string]; + comingSoon: boolean; + route?: string; +} + +const PACK_TYPES: PackTypeCard[] = [ + { + id: "questions", + title: "Question Packs", + description: "Explore intimate questions together and discover what you both want", + icon: "layers", + color: colors.primary, + colorRgba: "rgba(233, 69, 96, ", + gradient: ["rgba(233, 69, 96, 0.25)", "rgba(155, 89, 182, 0.25)"], + comingSoon: false, + route: "/(app)/packs/questions", + }, + { + id: "dares", + title: "Dare Packs", + description: "Spice things up with exciting dares for adventurous couples", + icon: "flash", + color: colors.premium.gold, + colorRgba: "rgba(212, 175, 55, ", + gradient: ["rgba(212, 175, 55, 0.25)", "rgba(184, 134, 11, 0.25)"], + comingSoon: true, + }, + { + id: "quiz", + title: "Quiz Packs", + description: "Test how well you know each other with fun couple quizzes", + icon: "help-circle", + color: colors.premium.rose, + colorRgba: "rgba(232, 164, 174, ", + gradient: ["rgba(232, 164, 174, 0.25)", "rgba(212, 145, 155, 0.25)"], + comingSoon: true, + }, +]; + +export default function PackTypeSelection() { + const { fetchPacks } = usePacksStore(); + + // Prefetch packs data so it's ready when navigating to questions + useEffect(() => { + fetchPacks(); + }, []); + + const handlePackTypePress = (packType: PackTypeCard) => { + if (!packType.comingSoon && packType.route) { + router.push(packType.route as any); + } + }; + + return ( + + + {/* Header */} + + EXPLORE + Content Packs + + {/* Decorative Separator */} + + + + + + + + Choose a pack type to explore + + + + {/* Pack Type Cards */} + + {PACK_TYPES.map((packType, index) => ( + + handlePackTypePress(packType)} + style={({ pressed }) => [ + styles.card, + pressed && !packType.comingSoon && styles.cardPressed, + packType.comingSoon && styles.cardDisabled, + ]} + disabled={packType.comingSoon} + > + + {/* Icon Circle */} + + + + + {/* Content */} + + + + {packType.title} + + {packType.comingSoon && ( + + Coming Soon + + )} + + + {packType.description} + + + + {/* Arrow */} + {!packType.comingSoon && ( + + )} + + + + ))} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + paddingTop: 60, + paddingHorizontal: spacing.lg, + paddingBottom: spacing.lg, + alignItems: "center", + }, + label: { + ...typography.caption1, + fontWeight: "600", + letterSpacing: 3, + color: colors.primary, + textAlign: "center", + marginBottom: spacing.xs, + }, + title: { + ...typography.title1, + color: colors.text, + textAlign: "center", + }, + separator: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + marginVertical: spacing.md, + width: 140, + }, + separatorLine: { + flex: 1, + height: 1, + backgroundColor: "rgba(233, 69, 96, 0.3)", + }, + separatorDiamond: { + width: 6, + height: 6, + backgroundColor: colors.primary, + transform: [{ rotate: "45deg" }], + marginHorizontal: spacing.md, + opacity: 0.6, + }, + subtitle: { + ...typography.body, + color: colors.textSecondary, + textAlign: "center", + }, + cardsContainer: { + paddingHorizontal: spacing.lg, + gap: spacing.md, + }, + card: { + borderRadius: radius.lg, + overflow: "hidden", + borderWidth: 1, + borderColor: colors.glass.border, + ...shadows.md, + }, + cardPressed: { + opacity: 0.9, + transform: [{ scale: 0.98 }], + }, + cardDisabled: { + opacity: 0.6, + }, + cardGradient: { + flexDirection: "row", + alignItems: "center", + padding: spacing.lg, + gap: spacing.md, + }, + iconCircle: { + width: 64, + height: 64, + borderRadius: 32, + justifyContent: "center", + alignItems: "center", + borderWidth: 1, + }, + cardContent: { + flex: 1, + }, + cardTitleRow: { + flexDirection: "row", + alignItems: "center", + gap: spacing.sm, + marginBottom: spacing.xs, + }, + cardTitle: { + ...typography.title3, + color: colors.text, + }, + cardTitleDisabled: { + color: colors.textSecondary, + }, + cardDescription: { + ...typography.subhead, + color: colors.textSecondary, + lineHeight: 20, + }, + cardDescriptionDisabled: { + color: colors.textTertiary, + }, + comingSoonBadge: { + backgroundColor: "rgba(255, 255, 255, 0.1)", + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: radius.full, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.15)", + }, + comingSoonText: { + ...typography.caption2, + color: colors.textTertiary, + fontWeight: "500", + textTransform: "uppercase", + letterSpacing: 0.5, + }, +}); diff --git a/apps/mobile/app/(app)/packs/questions.tsx b/apps/mobile/app/(app)/packs/questions.tsx new file mode 100644 index 0000000..007eadb --- /dev/null +++ b/apps/mobile/app/(app)/packs/questions.tsx @@ -0,0 +1,711 @@ +import { View, Text, StyleSheet, RefreshControl, Platform, Alert, Pressable } from "react-native"; +import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../../src/store"; +import { Ionicons } from "@expo/vector-icons"; +import { router } from "expo-router"; +import { useMemo, useState, useCallback } from "react"; +import Animated, { + FadeIn, + FadeOut, + useAnimatedStyle, + useSharedValue, + withTiming, + useAnimatedScrollHandler, + interpolate, + Extrapolation, +} from "react-native-reanimated"; +import { BlurView } from "expo-blur"; +import { GradientBackground } from "../../../src/components/ui"; +import { Paywall } from "../../../src/components/Paywall"; +import { PackTeaser } from "../../../src/components/PackTeaser"; +import { colors, spacing, radius, typography } from "../../../src/theme"; +import type { QuestionPack, Category } from "../../../src/types"; + +const HEADER_MAX_HEIGHT = 180; +const HEADER_MIN_HEIGHT = 100; +const HEADER_SCROLL_DISTANCE = HEADER_MAX_HEIGHT - HEADER_MIN_HEIGHT; + +interface PackRowProps { + pack: QuestionPack; + isEnabled: boolean; + isPremiumLocked: boolean; + isExpanded: boolean; + onToggle: () => void; + onPress: () => void; + onInfoPress: () => void; +} + +function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPress, onInfoPress }: PackRowProps) { + const questionCount = pack.questions?.[0]?.count || 0; + + return ( + + + + {pack.icon || "📦"} + + + + + {pack.name} + {isPremiumLocked && ( + + )} + + + {questionCount} {questionCount === 1 ? "question" : "questions"} + {isEnabled && · Enabled} + + + + + + + {isExpanded && ( + + {pack.description && ( + {pack.description} + )} + + + + Preview + + + + {isPremiumLocked ? ( + <> + + + Unlock + + + ) : isEnabled ? ( + <> + + + Enabled + + + ) : ( + <> + + Enable + + )} + + + + )} + + ); +} + +interface CategorySectionProps { + category: Category; + packs: QuestionPack[]; + enabledPackIds: string[]; + hasPremiumAccess: boolean; + expandedPackId: string | null; + onPackPress: (packId: string) => void; + onPackToggle: (pack: QuestionPack) => void; + onPackInfo: (pack: QuestionPack) => void; +} + +function CategorySection({ + category, + packs, + enabledPackIds, + hasPremiumAccess, + expandedPackId, + onPackPress, + onPackToggle, + onPackInfo, +}: CategorySectionProps) { + const [isCollapsed, setIsCollapsed] = useState(false); + const enabledCount = packs.filter(p => enabledPackIds.includes(p.id)).length; + + const rotateAnim = useSharedValue(0); + + const chevronStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotateAnim.value}deg` }], + })); + + const handleToggleCollapse = () => { + rotateAnim.value = withTiming(isCollapsed ? 0 : -90, { duration: 200 }); + setIsCollapsed(!isCollapsed); + }; + + return ( + + + + {category.icon && ( + {category.icon} + )} + {category.name} + {enabledCount > 0 && ( + + {enabledCount} + + )} + + + + + + + {!isCollapsed && ( + + {packs.map((pack) => ( + onPackPress(pack.id)} + onToggle={() => onPackToggle(pack)} + onInfoPress={() => onPackInfo(pack)} + /> + ))} + + )} + + ); +} + +export default function QuestionPacksScreen() { + const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore(); + const { user, partner } = useAuthStore(); + const { subscription } = useSubscriptionStore(); + + const [expandedPackId, setExpandedPackId] = useState(null); + const [showPaywall, setShowPaywall] = useState(false); + const [showTeaser, setShowTeaser] = useState(false); + const [teaserPack, setTeaserPack] = useState(null); + + const scrollY = useSharedValue(0); + + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + scrollY.value = event.contentOffset.y; + }, + }); + + const hasPremiumAccess = useMemo(() => { + return user?.is_premium || partner?.is_premium || subscription.isProUser; + }, [user?.is_premium, partner?.is_premium, subscription.isProUser]); + + const packsByCategory = useMemo(() => { + const grouped = new Map(); + categories.forEach(cat => grouped.set(cat.id, [])); + grouped.set("uncategorized", []); + packs.forEach(pack => { + const catId = pack.category_id || "uncategorized"; + const existing = grouped.get(catId) || []; + existing.push(pack); + grouped.set(catId, existing); + }); + return grouped; + }, [packs, categories]); + + const handlePackPress = useCallback((packId: string) => { + setExpandedPackId(current => current === packId ? null : packId); + }, []); + + const handlePackToggle = useCallback(async (pack: QuestionPack) => { + if (pack.is_premium && !hasPremiumAccess) { + setTeaserPack(pack); + setShowTeaser(true); + return; + } + const result = await togglePack(pack.id); + if (!result.success && result.reason === "no_couple") { + Alert.alert( + "Pair with Partner", + "You need to pair with your partner before you can customise question packs.", + [ + { text: "Later", style: "cancel" }, + { text: "Pair Now", onPress: () => router.push("/pairing") } + ] + ); + } + }, [hasPremiumAccess, togglePack]); + + const handlePackInfo = useCallback((pack: QuestionPack) => { + const isPremiumLocked = pack.is_premium && !hasPremiumAccess; + if (isPremiumLocked) { + setTeaserPack(pack); + setShowTeaser(true); + } else { + router.push(`/pack/${pack.id}`); + } + }, [hasPremiumAccess]); + + const handleTeaserUnlock = useCallback(() => { + setShowTeaser(false); + setShowPaywall(true); + }, []); + + const handleTeaserClose = useCallback(() => { + setShowTeaser(false); + setTeaserPack(null); + }, []); + + const enabledPackCount = enabledPackIds.length; + const totalQuestions = packs + .filter(p => enabledPackIds.includes(p.id)) + .reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0); + + // Animated styles for collapsing header + const largeHeaderStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE * 0.5], + [1, 0], + Extrapolation.CLAMP + ); + const translateY = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE], + [0, -20], + Extrapolation.CLAMP + ); + return { opacity, transform: [{ translateY }] }; + }); + + const compactHeaderStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE], + [0, 1], + Extrapolation.CLAMP + ); + return { opacity }; + }); + + const navBarBackgroundStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE * 0.8], + [0, 1], + Extrapolation.CLAMP + ); + return { opacity }; + }); + + return ( + + + {/* Fixed Nav Bar */} + + {/* Nav bar background with blur */} + + {Platform.OS === "ios" ? ( + + ) : ( + + )} + + + {/* Back button */} + router.back()} + hitSlop={8} + > + + + + {/* Compact title (fades in on scroll) */} + + Question Packs + + + {/* Spacer for alignment */} + + + + {/* Scrollable Content */} + + } + contentContainerStyle={styles.scrollContent} + > + {/* Large Header (fades out on scroll) */} + + COLLECTION + Question Packs + + + + + + + + + + + {totalQuestions} {totalQuestions === 1 ? "question" : "questions"} from {enabledPackCount} {enabledPackCount === 1 ? "pack" : "packs"} + + + + + {/* Categories */} + + {categories.map(category => { + const categoryPacks = packsByCategory.get(category.id) || []; + if (categoryPacks.length === 0) return null; + + return ( + + ); + })} + + {(packsByCategory.get("uncategorized")?.length || 0) > 0 && ( + + )} + + + + {/* Modals */} + {teaserPack && ( + + )} + + setShowPaywall(false)} + onSuccess={() => fetchPacks()} + /> + + + ); +} + +const NAV_BAR_HEIGHT = 44; +const STATUS_BAR_HEIGHT = 60; // Match other pages + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + + // Fixed Nav Bar + navBar: { + position: "absolute", + top: 0, + left: 0, + right: 0, + height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT, + flexDirection: "row", + alignItems: "center", + paddingTop: STATUS_BAR_HEIGHT - 10, + paddingHorizontal: spacing.md, + zIndex: 100, + }, + navBarBackground: { + ...StyleSheet.absoluteFillObject, + borderBottomWidth: 1, + borderBottomColor: "rgba(255, 255, 255, 0.1)", + overflow: "hidden", + }, + backButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: "rgba(255, 255, 255, 0.1)", + justifyContent: "center", + alignItems: "center", + }, + navBarTitle: { + flex: 1, + ...typography.headline, + color: colors.text, + textAlign: "center", + marginRight: 40, // Balance with back button + }, + navBarSpacer: { + width: 0, + }, + + // Large Header + largeHeader: { + alignItems: "center", + paddingBottom: spacing.lg, + }, + label: { + ...typography.caption1, + fontWeight: "600", + letterSpacing: 3, + color: colors.primary, + textAlign: "center", + marginBottom: spacing.xs, + }, + title: { + ...typography.title1, + color: colors.text, + textAlign: "center", + }, + separator: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + marginVertical: spacing.md, + width: 140, + }, + separatorLine: { + flex: 1, + height: 1, + backgroundColor: "rgba(233, 69, 96, 0.3)", + }, + separatorDiamond: { + width: 6, + height: 6, + backgroundColor: colors.primary, + transform: [{ rotate: "45deg" }], + marginHorizontal: spacing.md, + opacity: 0.6, + }, + statsBadge: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "rgba(233, 69, 96, 0.1)", + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.full, + borderWidth: 1, + borderColor: "rgba(233, 69, 96, 0.2)", + gap: spacing.sm, + }, + statsBadgeText: { + ...typography.caption1, + color: colors.textSecondary, + fontWeight: "500", + }, + + // Scroll Content + scrollContent: { + paddingTop: 60, // Match other pages + paddingBottom: Platform.OS === "ios" ? 120 : 100, + }, + categoriesContainer: { + paddingHorizontal: spacing.lg, + }, + + // Category Section + categorySection: { + marginBottom: spacing.lg, + }, + categoryHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: spacing.sm, + marginBottom: spacing.xs, + }, + categoryHeaderLeft: { + flexDirection: "row", + alignItems: "center", + gap: spacing.sm, + }, + categoryIcon: { + fontSize: 18, + }, + categoryName: { + ...typography.headline, + color: colors.text, + textTransform: "uppercase", + letterSpacing: 1, + fontSize: 13, + }, + categoryBadge: { + backgroundColor: colors.primary, + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 10, + minWidth: 20, + alignItems: "center", + }, + categoryBadgeText: { + ...typography.caption2, + color: colors.text, + fontWeight: "600", + }, + packsList: { + backgroundColor: "rgba(255, 255, 255, 0.03)", + borderRadius: radius.lg, + borderWidth: 1, + borderColor: colors.glass.border, + overflow: "hidden", + }, + + // Pack Row + packRow: { + borderBottomWidth: 1, + borderBottomColor: colors.glass.border, + }, + packRowMain: { + flexDirection: "row", + alignItems: "center", + padding: spacing.md, + gap: spacing.md, + }, + packIcon: { + width: 44, + height: 44, + borderRadius: 12, + backgroundColor: "rgba(255, 255, 255, 0.05)", + justifyContent: "center", + alignItems: "center", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.08)", + }, + packIconEnabled: { + backgroundColor: "rgba(233, 69, 96, 0.1)", + borderColor: "rgba(233, 69, 96, 0.2)", + }, + packIconText: { + fontSize: 20, + }, + packContent: { + flex: 1, + }, + packTitleRow: { + flexDirection: "row", + alignItems: "center", + gap: spacing.xs, + }, + packName: { + ...typography.body, + color: colors.text, + fontWeight: "500", + }, + packMeta: { + ...typography.caption1, + color: colors.textTertiary, + marginTop: 2, + }, + packMetaEnabled: { + color: colors.primary, + }, + + // Expanded Pack + packExpanded: { + paddingHorizontal: spacing.md, + paddingBottom: spacing.md, + paddingTop: 0, + }, + packDescription: { + ...typography.subhead, + color: colors.textSecondary, + lineHeight: 20, + marginBottom: spacing.md, + }, + packActions: { + flexDirection: "row", + gap: spacing.sm, + }, + packInfoButton: { + flexDirection: "row", + alignItems: "center", + gap: spacing.xs, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: radius.sm, + backgroundColor: "rgba(255, 255, 255, 0.05)", + borderWidth: 1, + borderColor: colors.glass.border, + }, + packInfoButtonText: { + ...typography.subhead, + color: colors.textSecondary, + }, + packToggleButton: { + flex: 1, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: spacing.xs, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: radius.sm, + backgroundColor: "rgba(255, 255, 255, 0.08)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.12)", + }, + packToggleButtonEnabled: { + backgroundColor: "rgba(233, 69, 96, 0.1)", + borderColor: "rgba(233, 69, 96, 0.3)", + }, + packToggleButtonLocked: { + backgroundColor: "rgba(212, 175, 55, 0.1)", + borderColor: "rgba(212, 175, 55, 0.3)", + }, + packToggleButtonText: { + ...typography.subhead, + color: colors.text, + fontWeight: "500", + }, + packToggleButtonTextEnabled: { + color: colors.primary, + }, + packToggleButtonTextLocked: { + color: colors.premium.gold, + }, +}); diff --git a/apps/mobile/app/(app)/pairing.tsx b/apps/mobile/app/(app)/pairing.tsx index 81d92ad..52233b7 100755 --- a/apps/mobile/app/(app)/pairing.tsx +++ b/apps/mobile/app/(app)/pairing.tsx @@ -408,7 +408,7 @@ const styles = StyleSheet.create({ alignItems: "center", }, header: { - paddingTop: Platform.OS === "ios" ? 60 : 40, + paddingTop: 60, paddingHorizontal: spacing.lg, paddingBottom: spacing.lg, flexDirection: "row", diff --git a/apps/mobile/app/(app)/profile.tsx b/apps/mobile/app/(app)/profile.tsx index 0a472a1..e45d735 100755 --- a/apps/mobile/app/(app)/profile.tsx +++ b/apps/mobile/app/(app)/profile.tsx @@ -22,9 +22,11 @@ import { GradientBackground, GlassCard } from "../../src/components/ui"; import { FeedbackModal } from "../../src/components/FeedbackModal"; import { Paywall } from "../../src/components/Paywall"; import { Events } from "../../src/lib/analytics"; -import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme"; +import { colors, gradients, spacing, radius, typography, shadows, featureColors } from "../../src/theme"; const MAX_CONTENT_WIDTH = 500; +const ACCENT_COLOR = featureColors.profile.accent; +const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string]; export default function ProfileScreen() { const { user, partner, couple, signOut, fetchCouple, fetchUser } = useAuthStore(); @@ -447,7 +449,13 @@ export default function ProfileScreen() { entering={FadeInDown.delay(100).duration(500)} style={styles.header} > + SETTINGS Profile + + + + + {/* Profile Card */} @@ -464,7 +472,7 @@ export default function ProfileScreen() { style={styles.avatarTouchable} > @@ -745,7 +753,7 @@ export default function ProfileScreen() { @@ -812,7 +820,7 @@ export default function ProfileScreen() { > - + Sauci v1.0.0 @@ -1125,11 +1133,40 @@ const styles = StyleSheet.create({ header: { paddingTop: 60, paddingHorizontal: spacing.lg, - paddingBottom: spacing.lg, + paddingBottom: spacing.md, + alignItems: 'center', + }, + headerLabel: { + ...typography.caption2, + fontWeight: '600', + letterSpacing: 3, + color: colors.secondary, + marginBottom: spacing.xs, }, title: { - ...typography.title1, + ...typography.largeTitle, color: colors.text, + textAlign: 'center', + }, + separator: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginVertical: spacing.md, + width: 100, + }, + separatorLine: { + flex: 1, + height: 1, + backgroundColor: 'rgba(155, 89, 182, 0.3)', + }, + separatorDiamond: { + width: 6, + height: 6, + backgroundColor: colors.secondary, + transform: [{ rotate: '45deg' }], + marginHorizontal: spacing.sm, + opacity: 0.6, }, profileSection: { paddingHorizontal: spacing.lg, @@ -1186,7 +1223,7 @@ const styles = StyleSheet.create({ width: 32, height: 32, borderRadius: 16, - backgroundColor: colors.primary, + backgroundColor: colors.secondary, justifyContent: "center", alignItems: "center", borderWidth: 3, @@ -1227,7 +1264,7 @@ const styles = StyleSheet.create({ backgroundColor: colors.glass.background, borderRadius: radius.md, borderWidth: 1, - borderColor: colors.primary, + borderColor: colors.secondary, paddingHorizontal: spacing.md, paddingVertical: spacing.sm, minWidth: 200, @@ -1250,7 +1287,7 @@ const styles = StyleSheet.create({ width: 36, height: 36, borderRadius: 18, - backgroundColor: colors.primary, + backgroundColor: colors.secondary, justifyContent: "center", alignItems: "center", }, @@ -1364,7 +1401,7 @@ const styles = StyleSheet.create({ }, manageButtonText: { ...typography.subhead, - color: colors.primary, + color: colors.secondary, fontWeight: "600", }, upgradeButton: { diff --git a/apps/mobile/app/(app)/quiz.tsx b/apps/mobile/app/(app)/quiz.tsx index 683d378..efbb019 100644 --- a/apps/mobile/app/(app)/quiz.tsx +++ b/apps/mobile/app/(app)/quiz.tsx @@ -33,11 +33,6 @@ export default function QuizScreen() { entering={FadeInDown.duration(600).springify()} style={styles.content} > - {/* Icon */} - - - - {/* Title section */} INTIMACY Quiz @@ -81,40 +76,39 @@ 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"} - - - )} - - - )} + {/* Interest button - always reserve space to prevent layout shift */} + + {isAuthenticated && !isLoading && ( + + + {isToggling ? ( + + ) : ( + <> + + + {isInterested ? "You're on the list!" : "I'm interested"} + + + )} + + + )} + @@ -127,7 +121,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', paddingHorizontal: spacing.lg, - paddingTop: Platform.OS === 'ios' ? 60 : 40, + paddingTop: 60, paddingBottom: Platform.OS === 'ios' ? 120 : 100, }, content: { @@ -135,17 +129,6 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingHorizontal: spacing.md, }, - iconContainer: { - width: 72, - height: 72, - borderRadius: 36, - backgroundColor: 'rgba(232, 164, 174, 0.1)', - borderWidth: 1, - borderColor: 'rgba(232, 164, 174, 0.2)', - justifyContent: 'center', - alignItems: 'center', - marginBottom: spacing.xl, - }, label: { ...typography.caption1, fontWeight: '600', @@ -223,6 +206,7 @@ const styles = StyleSheet.create({ }, interestButtonContainer: { marginTop: spacing.xl, + minHeight: 44, }, interestButton: { flexDirection: 'row', diff --git a/apps/mobile/app/(app)/swipe.tsx b/apps/mobile/app/(app)/swipe.tsx index 5471840..50d26e3 100755 --- a/apps/mobile/app/(app)/swipe.tsx +++ b/apps/mobile/app/(app)/swipe.tsx @@ -19,7 +19,8 @@ import { usePacksStore, useAuthStore } from "../../src/store"; import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions"; import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen"; import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler"; -import SwipeCard from "../../src/components/SwipeCard"; +// import SwipeCard from "../../src/components/SwipeCard"; +import SwipeCard from "../../src/components/SwipeCardPremium"; // PoC: Premium styling import SwipeTutorial from "../../src/components/SwipeTutorial"; import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui"; import { Events } from "../../src/lib/analytics"; @@ -115,6 +116,7 @@ export default function SwipeScreen() { const checkAnswerGap = async (): Promise => { if (!couple || !partner) { setIsBlocked(false); + setGapInfo(null); return false; } @@ -129,10 +131,15 @@ export default function SwipeScreen() { if (data && data.length > 0) { const result = data[0]; setIsBlocked(result.is_blocked); - setGapInfo({ - unanswered: result.unanswered_by_partner, - threshold: result.threshold - }); + // Only set gap info if threshold is enabled (> 0) + if (result.threshold > 0) { + setGapInfo({ + unanswered: result.unanswered_by_partner, + threshold: result.threshold + }); + } else { + setGapInfo(null); + } return result.is_blocked; } } catch (error) { @@ -141,6 +148,18 @@ export default function SwipeScreen() { return false; }; + // Calculate effective total questions (accounting for gap limit) + const effectiveTotal = (() => { + if (!gapInfo || gapInfo.threshold === 0) { + // No gap limit active, show all questions + return questions.length; + } + // remaining = how many more questions until blocked + const remaining = gapInfo.threshold - gapInfo.unanswered; + // effective total = current position + remaining (capped by actual questions) + return Math.min(questions.length, currentIndex + Math.max(0, remaining)); + })(); + // Track when all questions are exhausted useEffect(() => { if (questions.length > 0 && currentIndex >= questions.length && !hasTrackedExhausted.current) { @@ -193,12 +212,14 @@ export default function SwipeScreen() { // Filter out recently skipped questions const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id)); - // If no questions returned and user has partner, check if it's due to answer gap - if (filtered.length === 0 && partner && !packId) { + // Always check answer gap when user has partner and not viewing specific pack + // This gives us the gap info for accurate counter display + if (partner && !packId) { await checkAnswerGap(); } else { - // Reset blocked state if we have questions + // Reset blocked state if no partner or viewing specific pack setIsBlocked(false); + setGapInfo(null); } const sorted = filtered.sort((a: any, b: any) => { @@ -554,10 +575,10 @@ export default function SwipeScreen() { return ( - {/* Ambient Orbs */} + {/* Ambient Orbs - Premium gold/rose */} + {/* Premium label */} + EXPLORE - {currentIndex + 1} of {questions.length} + {currentIndex + 1} of {effectiveTotal || questions.length} - + - {/* Shimmer sweep */} - {/* Hint */} + {/* Hint - Premium styling */} - Swipe or tap to answer + Swipe or tap to answer {/* Bottom spacing for tab bar */} @@ -686,6 +704,13 @@ const styles = StyleSheet.create({ progressContainer: { alignItems: "center", }, + progressLabel: { + ...typography.caption2, + fontWeight: '600', + letterSpacing: 3, + color: colors.premium.gold, + marginBottom: spacing.xs, + }, progressText: { ...typography.subhead, color: colors.textSecondary, @@ -698,18 +723,23 @@ const styles = StyleSheet.create({ borderRadius: 3, overflow: "hidden", }, - progressBarEnhanced: { - height: 8, - borderRadius: 4, + progressBarPremium: { + width: 140, + height: 4, + borderRadius: 2, + backgroundColor: 'rgba(212, 175, 55, 0.1)', borderWidth: 1, - borderColor: colors.glass.borderLight, - ...shadows.glow(colors.primaryGlow), + borderColor: 'rgba(212, 175, 55, 0.15)', }, progressFill: { height: "100%", borderRadius: 4, overflow: "hidden", }, + progressFillPremium: { + backgroundColor: 'rgba(212, 175, 55, 0.5)', + borderRadius: 2, + }, progressGradient: { flex: 1, borderRadius: 4, @@ -725,24 +755,24 @@ const styles = StyleSheet.create({ justifyContent: "center", alignItems: "center", }, - // Card stack + // Card stack - Premium styling stackCard: { position: "absolute", width: "85%", - height: 480, - backgroundColor: colors.glass.backgroundLight, - borderRadius: radius.xxl, + height: 500, + backgroundColor: '#0d0d1a', + borderRadius: radius.xl, borderWidth: 1, - borderColor: colors.glass.border, + borderColor: 'rgba(212, 175, 55, 0.15)', }, stackCardSecond: { transform: [{ scale: 0.95 }, { translateY: 12 }], - opacity: 0.5, + opacity: 0.4, ...shadows.md, }, stackCardThird: { transform: [{ scale: 0.90 }, { translateY: 24 }], - opacity: 0.25, + opacity: 0.2, ...shadows.sm, }, hintContainer: { @@ -753,6 +783,12 @@ const styles = StyleSheet.create({ ...typography.caption1, color: colors.textTertiary, }, + hintTextPremium: { + ...typography.caption1, + fontStyle: 'italic', + color: colors.textTertiary, + letterSpacing: 0.5, + }, bottomSpacer: { height: Platform.OS === 'ios' ? 100 : 80, }, diff --git a/apps/mobile/app/pack/[id].tsx b/apps/mobile/app/pack/[id].tsx index 48a3641..b8a6782 100644 --- a/apps/mobile/app/pack/[id].tsx +++ b/apps/mobile/app/pack/[id].tsx @@ -6,9 +6,16 @@ import { BlurView } from "expo-blur"; import { supabase } from "../../src/lib/supabase"; 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 Animated, { + FadeInDown, + useSharedValue, + useAnimatedScrollHandler, + useAnimatedStyle, + interpolate, + Extrapolation, +} from "react-native-reanimated"; +import { GradientBackground, GlassCard } from "../../src/components/ui"; +import { colors, gradients, spacing, radius, typography } from "../../src/theme"; import { Question } from "../../src/types"; type TooltipType = 'initiator' | 'coupleType' | null; @@ -24,13 +31,14 @@ 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)'], -]; +const ACCENT_COLOR = colors.primary; + +const NAV_BAR_HEIGHT = 44; +const STATUS_BAR_HEIGHT = 60; +const HERO_HEIGHT = 260; +const HEADER_SCROLL_DISTANCE = 120; + +const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); export default function PackDetailsScreen() { const { id } = useLocalSearchParams(); @@ -42,11 +50,12 @@ export default function PackDetailsScreen() { const [isLoading, setIsLoading] = useState(true); const [activeTooltip, setActiveTooltip] = useState(null); + const scrollY = useSharedValue(0); + 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); @@ -74,23 +83,57 @@ export default function PackDetailsScreen() { } }; + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + scrollY.value = event.contentOffset.y; + }, + }); + + // Animated styles for collapsing header + const heroStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE * 0.7], + [1, 0], + Extrapolation.CLAMP + ); + const scale = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE], + [1, 0.95], + Extrapolation.CLAMP + ); + return { opacity, transform: [{ scale }] }; + }); + + const compactHeaderStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE], + [0, 1], + Extrapolation.CLAMP + ); + return { opacity }; + }); + + const navBarBackgroundStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE * 0.8], + [0, 1], + Extrapolation.CLAMP + ); + return { opacity }; + }); + const useBlur = Platform.OS === 'ios'; const renderItem = ({ item, index }: { item: Question; index: number }) => { - const cardGradient = QUESTION_GRADIENTS[index % QUESTION_GRADIENTS.length]; - return ( - - {/* Subtle gradient overlay */} - - + + #{index + 1} {item.text} {item.partner_text && ( @@ -101,41 +144,37 @@ export default function PackDetailsScreen() { )} - {/* 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 } : {}} + size={12} + color={colors.textTertiary} + style={i > 0 ? { marginLeft: 2 } : {}} /> ))} - )} - {/* Couple type indicator */} setActiveTooltip('coupleType')} @@ -157,8 +196,8 @@ export default function PackDetailsScreen() { 0 ? { marginLeft: 2 } : {}} /> ))} @@ -166,18 +205,109 @@ export default function PackDetailsScreen() { ); }) ) : ( - + )} - - + ); }; + const heroContent = ( + + {/* Premium Banner */} + {pack?.is_premium && ( + + + + + PREMIUM COLLECTION + + + + )} + + + {/* Category label */} + + {category ? category.name.toUpperCase() : 'PREVIEW'} + + + {/* Icon with enhanced glow for premium */} + + {pack?.is_premium && ( + <> + + + + )} + + {pack?.is_premium && ( + + )} + {pack?.icon || "📦"} + + {/* Crown for premium */} + {pack?.is_premium && ( + + + + )} + + + {/* Title */} + + {pack?.name || "Pack Details"} + + + {/* Premium badge - larger and more prominent */} + {pack?.is_premium && ( + + + + PREMIUM + + + )} + + {/* Decorative separator */} + + + + + + + {/* Description */} + {pack?.description} + + {/* Stats badge */} + + + {questions.length} questions + + + + ); + if (isLoading) { return ( @@ -188,103 +318,46 @@ export default function PackDetailsScreen() { ); } - 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 - - - - ); - return ( - {/* Hero Section */} - - {isPremium ? ( - - {heroContent} - - ) : ( - - {heroContent} - - )} - + {/* Fixed Nav Bar */} + + + {Platform.OS === "ios" ? ( + + ) : ( + + )} + - {/* Questions List */} - router.back()} style={styles.backButton}> + + + + + + + {pack?.name || "Pack Details"} + + + + + + {/* Questions List with Hero Header */} + item.id} - contentContainerStyle={styles.list} showsVerticalScrollIndicator={false} + onScroll={scrollHandler} + scrollEventThrottle={16} + contentContainerStyle={styles.list} + ListHeaderComponent={ + + {heroContent} + + } ListEmptyComponent={ @@ -345,92 +418,224 @@ const styles = StyleSheet.create({ justifyContent: "center", alignItems: "center", }, - heroContainer: { - borderBottomLeftRadius: radius.xl, - borderBottomRightRadius: radius.xl, - overflow: 'hidden', - ...shadows.lg, + + // Fixed Nav Bar + navBar: { + position: "absolute", + top: 0, + left: 0, + right: 0, + height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT, + flexDirection: "row", + alignItems: "center", + paddingTop: STATUS_BAR_HEIGHT - 10, + paddingHorizontal: spacing.md, + zIndex: 100, }, - heroInner: { - paddingTop: 50, - paddingBottom: spacing.lg, - minHeight: 260, - }, - heroBlur: { - backgroundColor: 'rgba(22, 33, 62, 0.4)', - }, - heroBlurFallback: { - backgroundColor: 'rgba(22, 33, 62, 0.6)', + navBarBackground: { + ...StyleSheet.absoluteFillObject, + borderBottomWidth: 1, + borderBottomColor: 'rgba(233, 69, 96, 0.15)', + overflow: "hidden", }, backButton: { - position: 'absolute', - top: 50, - left: spacing.md, zIndex: 10, }, backButtonInner: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: 'rgba(0, 0, 0, 0.3)', + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: 'rgba(255, 255, 255, 0.08)', justifyContent: 'center', alignItems: 'center', borderWidth: 1, - borderColor: colors.glass.border, + borderColor: 'rgba(255, 255, 255, 0.1)', + }, + navBarTitle: { + flex: 1, + ...typography.headline, + color: colors.text, + textAlign: "center", + marginRight: 36, + }, + navBarSpacer: { + width: 0, + }, + + // Hero Section + heroContainer: { + marginBottom: spacing.lg, + }, + heroInner: { + paddingTop: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT + spacing.md, + paddingBottom: spacing.lg, + minHeight: HERO_HEIGHT, }, heroContent: { alignItems: 'center', paddingHorizontal: spacing.lg, - paddingTop: spacing.xl, + }, + + // Premium Banner + premiumBanner: { + marginHorizontal: spacing.lg, + marginBottom: spacing.lg, + borderRadius: radius.md, + overflow: 'hidden', + shadowColor: colors.premium.gold, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }, + premiumBannerContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + gap: spacing.sm, + }, + premiumBannerText: { + ...typography.caption1, + fontWeight: '800', + letterSpacing: 2, + color: colors.background, + }, + + label: { + ...typography.caption1, + fontWeight: '600', + letterSpacing: 3, + color: ACCENT_COLOR, + textAlign: 'center', + marginBottom: spacing.md, + }, + labelPremium: { + color: colors.premium.gold, + }, + iconWrapper: { + position: 'relative', + marginBottom: spacing.md, + }, + iconGlowOuter: { + position: 'absolute', + top: -20, + left: -20, + right: -20, + bottom: -20, + borderRadius: 60, + backgroundColor: 'rgba(212, 175, 55, 0.08)', + }, + iconGlow: { + position: 'absolute', + top: -10, + left: -10, + right: -10, + bottom: -10, + borderRadius: 50, + backgroundColor: 'rgba(212, 175, 55, 0.2)', }, iconContainer: { - width: 80, - height: 80, - borderRadius: 40, - backgroundColor: 'rgba(255, 255, 255, 0.1)', + width: 72, + height: 72, + borderRadius: 36, + backgroundColor: 'rgba(255, 255, 255, 0.06)', justifyContent: 'center', alignItems: 'center', - marginBottom: spacing.md, - borderWidth: 2, - borderColor: 'rgba(255, 255, 255, 0.15)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + overflow: 'hidden', }, iconContainerPremium: { borderColor: colors.premium.gold, - backgroundColor: colors.premium.goldLight, + borderWidth: 2, + backgroundColor: 'rgba(212, 175, 55, 0.15)', + shadowColor: colors.premium.gold, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.5, + shadowRadius: 12, + elevation: 8, + }, + crownBadge: { + position: 'absolute', + top: -4, + right: -4, + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: colors.background, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: colors.premium.gold, + shadowColor: colors.premium.gold, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.4, + shadowRadius: 4, + elevation: 4, }, emoji: { - fontSize: 40, + fontSize: 36, + }, + title: { + ...typography.largeTitle, + color: colors.text, + textAlign: 'center', + marginBottom: spacing.sm, + }, + titlePremium: { + textShadowColor: 'rgba(212, 175, 55, 0.3)', + textShadowOffset: { width: 0, height: 0 }, + textShadowRadius: 10, }, proBadge: { flexDirection: 'row', alignItems: 'center', - backgroundColor: colors.premium.gold, - paddingHorizontal: spacing.sm, - paddingVertical: 4, - borderRadius: radius.sm, - gap: 4, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: radius.full, + gap: spacing.xs, marginBottom: spacing.sm, + overflow: 'hidden', + shadowColor: colors.premium.gold, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.4, + shadowRadius: 6, + elevation: 6, }, proBadgeText: { ...typography.caption1, - color: colors.text, - fontWeight: '700', + color: colors.background, + fontWeight: '800', + letterSpacing: 1.5, }, - breadcrumb: { + separator: { flexDirection: 'row', alignItems: 'center', - gap: 4, - marginBottom: spacing.xs, + justifyContent: 'center', + marginVertical: spacing.md, + width: 140, }, - breadcrumbText: { - ...typography.caption1, - color: colors.textSecondary, + separatorLine: { + flex: 1, + height: 1, + backgroundColor: 'rgba(233, 69, 96, 0.25)', }, - title: { - ...typography.title2, - color: colors.text, - textAlign: 'center', - marginBottom: spacing.xs, + separatorLinePremium: { + backgroundColor: 'rgba(212, 175, 55, 0.4)', + }, + separatorDiamond: { + width: 6, + height: 6, + backgroundColor: ACCENT_COLOR, + transform: [{ rotate: '45deg' }], + marginHorizontal: spacing.md, + opacity: 0.6, + }, + separatorDiamondPremium: { + backgroundColor: colors.premium.gold, + opacity: 1, }, description: { ...typography.body, @@ -438,37 +643,57 @@ const styles = StyleSheet.create({ textAlign: 'center', lineHeight: 24, paddingHorizontal: spacing.md, + marginBottom: spacing.md, }, statsBadge: { - marginTop: spacing.md, - backgroundColor: 'rgba(255, 255, 255, 0.1)', - paddingHorizontal: spacing.md, - paddingVertical: spacing.xs, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + backgroundColor: 'rgba(233, 69, 96, 0.1)', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, borderRadius: radius.full, borderWidth: 1, - borderColor: colors.glass.border, + borderColor: 'rgba(233, 69, 96, 0.2)', + }, + statsBadgePremium: { + backgroundColor: 'rgba(212, 175, 55, 0.1)', + borderColor: 'rgba(212, 175, 55, 0.3)', }, statsText: { ...typography.subhead, color: colors.textSecondary, fontWeight: '500', }, + statsTextPremium: { + color: colors.premium.champagne, + }, + + // List list: { - padding: spacing.md, + paddingHorizontal: spacing.md, paddingBottom: Platform.OS === 'ios' ? 100 : 80, }, questionCard: { - marginBottom: spacing.md, - overflow: 'hidden', + backgroundColor: 'rgba(22, 33, 62, 0.4)', + borderRadius: radius.md, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.06)', + marginBottom: spacing.sm, }, questionContent: { padding: spacing.md, }, + questionNumber: { + ...typography.caption2, + color: colors.textTertiary, + marginBottom: spacing.xs, + }, questionText: { ...typography.body, color: colors.text, marginBottom: spacing.sm, - lineHeight: 24, + lineHeight: 22, }, partnerTextContainer: { flexDirection: 'row', @@ -477,7 +702,7 @@ const styles = StyleSheet.create({ marginBottom: spacing.sm, paddingVertical: spacing.xs, paddingHorizontal: spacing.sm, - backgroundColor: colors.premium.roseLight, + backgroundColor: 'rgba(232, 164, 174, 0.1)', borderRadius: radius.sm, alignSelf: 'flex-start', }, @@ -491,23 +716,22 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'space-between', marginTop: spacing.xs, + paddingTop: spacing.xs, + borderTopWidth: 1, + borderTopColor: 'rgba(255, 255, 255, 0.04)', }, intensityContainer: { flexDirection: 'row', }, badgesContainer: { flexDirection: 'row', - gap: spacing.sm, + gap: spacing.xs, }, targetBadge: { flexDirection: 'row', alignItems: 'center', - backgroundColor: 'rgba(255, 255, 255, 0.08)', - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - borderRadius: radius.sm, - borderWidth: 1, - borderColor: colors.glass.border, + paddingHorizontal: spacing.xs, + paddingVertical: 2, }, coupleIconGroup: { flexDirection: 'row', diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index 20d7510..4a463d8 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -1,8 +1,21 @@ PODS: + - AppAuth (2.0.0): + - AppAuth/Core (= 2.0.0) + - AppAuth/ExternalUserAgent (= 2.0.0) + - AppAuth/Core (2.0.0) + - AppAuth/ExternalUserAgent (2.0.0): + - AppAuth/Core + - AppCheckCore (11.2.0): + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - PromisesObjC (~> 2.4) - boost (1.84.0) - DoubleConversion (1.1.6) - EXApplication (6.0.2): - ExpoModulesCore + - EXAV (15.0.2): + - ExpoModulesCore + - ReactCommon/turbomodule/core - EXConstants (17.0.8): - ExpoModulesCore - EXImageLoader (5.0.0): @@ -222,6 +235,10 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - ExpoAdapterGoogleSignIn (16.1.1): + - ExpoModulesCore + - GoogleSignIn (~> 9.0) + - React-Core - ExpoAppleAuthentication (7.1.3): - ExpoModulesCore - ExpoAsset (11.0.5): @@ -285,6 +302,24 @@ PODS: - FBLazyVector (0.76.9) - fmt (11.0.2) - glog (0.3.5) + - GoogleSignIn (9.0.0): + - AppAuth (~> 2.0) + - AppCheckCore (~> 11.0) + - GTMAppAuth (~> 5.0) + - GTMSessionFetcher/Core (~> 3.3) + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMAppAuth (5.0.0): + - AppAuth/Core (~> 2.0) + - GTMSessionFetcher/Core (< 4.0, >= 3.3) + - GTMSessionFetcher/Core (3.5.0) - hermes-engine (0.76.9): - hermes-engine/Pre-built (= 0.76.9) - hermes-engine/Pre-built (0.76.9) @@ -293,6 +328,7 @@ PODS: - libavif/core - libdav1d (>= 0.6.0) - libdav1d (1.2.0) + - PromisesObjC (2.4.0) - PurchasesHybridCommon (17.25.0): - RevenueCat (= 5.51.1) - RCT-Folly (2024.10.14.00): @@ -1573,6 +1609,27 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - react-native-compressor (1.13.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.10.14.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - react-native-safe-area-context (4.12.0): - React-Core - React-nativeconfig (0.76.9) @@ -1871,6 +1928,9 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - RNGoogleSignin (16.1.1): + - GoogleSignIn (~> 9.0) + - React-Core - RNPurchases (9.6.12): - PurchasesHybridCommon (= 17.25.0) - React-Core @@ -2022,6 +2082,7 @@ DEPENDENCIES: - boost (from `../../../node_modules/react-native/third-party-podspecs/boost.podspec`) - DoubleConversion (from `../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - EXApplication (from `../../../node_modules/expo-application/ios`) + - EXAV (from `../../../node_modules/expo-av/ios`) - EXConstants (from `../../../node_modules/expo-constants/ios`) - EXImageLoader (from `../../../node_modules/expo-image-loader/ios`) - EXJSONUtils (from `../../../node_modules/expo-json-utils/ios`) @@ -2032,6 +2093,7 @@ DEPENDENCIES: - expo-dev-launcher (from `../../../node_modules/expo-dev-launcher`) - expo-dev-menu (from `../../../node_modules/expo-dev-menu`) - expo-dev-menu-interface (from `../../../node_modules/expo-dev-menu-interface/ios`) + - "ExpoAdapterGoogleSignIn (from `../../../node_modules/@react-native-google-signin/google-signin/expo/ios`)" - ExpoAppleAuthentication (from `../../../node_modules/expo-apple-authentication/ios`) - ExpoAsset (from `../../../node_modules/expo-asset/ios`) - ExpoBlur (from `../../../node_modules/expo-blur/ios`) @@ -2085,6 +2147,7 @@ DEPENDENCIES: - React-logger (from `../../../node_modules/react-native/ReactCommon/logger`) - React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - react-native-compressor (from `../../../node_modules/react-native-compressor`) - react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`) - React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`) - React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) @@ -2115,6 +2178,7 @@ DEPENDENCIES: - ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`) - "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)" - RNGestureHandler (from `../../../node_modules/react-native-gesture-handler`) + - "RNGoogleSignin (from `../../../node_modules/@react-native-google-signin/google-signin`)" - RNPurchases (from `../../../node_modules/react-native-purchases`) - RNReanimated (from `../../../node_modules/react-native-reanimated`) - RNScreens (from `../../../node_modules/react-native-screens`) @@ -2123,8 +2187,15 @@ DEPENDENCIES: SPEC REPOS: trunk: + - AppAuth + - AppCheckCore + - GoogleSignIn + - GoogleUtilities + - GTMAppAuth + - GTMSessionFetcher - libavif - libdav1d + - PromisesObjC - PurchasesHybridCommon - RevenueCat - SDWebImage @@ -2140,6 +2211,8 @@ EXTERNAL SOURCES: :podspec: "../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" EXApplication: :path: "../../../node_modules/expo-application/ios" + EXAV: + :path: "../../../node_modules/expo-av/ios" EXConstants: :path: "../../../node_modules/expo-constants/ios" EXImageLoader: @@ -2160,6 +2233,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/expo-dev-menu" expo-dev-menu-interface: :path: "../../../node_modules/expo-dev-menu-interface/ios" + ExpoAdapterGoogleSignIn: + :path: "../../../node_modules/@react-native-google-signin/google-signin/expo/ios" ExpoAppleAuthentication: :path: "../../../node_modules/expo-apple-authentication/ios" ExpoAsset: @@ -2263,6 +2338,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native/ReactCommon" React-microtasksnativemodule: :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + react-native-compressor: + :path: "../../../node_modules/react-native-compressor" react-native-safe-area-context: :path: "../../../node_modules/react-native-safe-area-context" React-nativeconfig: @@ -2323,6 +2400,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/@react-native-async-storage/async-storage" RNGestureHandler: :path: "../../../node_modules/react-native-gesture-handler" + RNGoogleSignin: + :path: "../../../node_modules/@react-native-google-signin/google-signin" RNPurchases: :path: "../../../node_modules/react-native-purchases" RNReanimated: @@ -2335,9 +2414,12 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: + AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 + AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f boost: 1dca942403ed9342f98334bf4c3621f011aa7946 DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 EXApplication: 4c72f6017a14a65e338c5e74fca418f35141e819 + EXAV: 4c41f0a6ae54a0b96597abc90e37962ab2ee0d85 EXConstants: fcfc75800824ac2d5c592b5bc74130bad17b146b EXImageLoader: e5da974e25b13585c196b658a440720c075482d5 EXJSONUtils: 01fc7492b66c234e395dcffdd5f53439c5c29c93 @@ -2348,6 +2430,7 @@ SPEC CHECKSUMS: expo-dev-launcher: 82de1bf29e91f4794bcc65a697e7066d9a826bee expo-dev-menu: 3fda3eb2b3394dc92955f6ff60cb278f843641be expo-dev-menu-interface: 00dc42302a72722fdecec3fa048de84a9133bcc4 + ExpoAdapterGoogleSignIn: 21ff1daeef3d21cb0b45772d8029102cc728d6b1 ExpoAppleAuthentication: 52631ed9dcb71c65712a447bbb9a5667bb8fcf0c ExpoAsset: 48386d40d53a8c1738929b3ed509bcad595b5516 ExpoBlur: 392c1207f71d0ecf22371621c1fbd44ba84d9742 @@ -2369,9 +2452,14 @@ SPEC CHECKSUMS: FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45 fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6 glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a + GoogleSignIn: c7f09cfbc85a1abf69187be091997c317cc33b77 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238 + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428 RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17 RCTDeprecation: ebe712bb05077934b16c6bf25228bdec34b64f83 @@ -2402,6 +2490,7 @@ SPEC CHECKSUMS: React-logger: c4052eb941cca9a097ef01b59543a656dc088559 React-Mapbuffer: 33546a3ebefbccb8770c33a1f8a5554fa96a54de React-microtasksnativemodule: 5c3d795318c22ab8df55100e50b151384a4a60b3 + react-native-compressor: 2c3a41768bdd367f6e121f555dff9d3eda1810e5 react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448 React-nativeconfig: 8efdb1ef1e9158c77098a93085438f7e7b463678 React-NativeModulesApple: cebca2e5320a3d66e123cade23bd90a167ffce5e @@ -2433,6 +2522,7 @@ SPEC CHECKSUMS: RevenueCat: eab035bbab271faccfef5c36eaff2a1ffef14dc0 RNCAsyncStorage: aa75595c1aefa18f868452091fa0c411a516ce11 RNGestureHandler: 32336458ef842088a459ccfcc10b583186de2633 + RNGoogleSignin: 5782dd7dad559608a06f3c6b1d4142c395fb5308 RNPurchases: cc3333cede6c0f040545a95c38ae627d0174c058 RNReanimated: 82bf292e3c590d2d9886039f83e1baf0c1b2cdc4 RNScreens: 295d9c0aaeb7f680d03d7e9b476569a4959aae89 diff --git a/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj b/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj index d512fab..86ee33b 100644 --- a/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj @@ -174,8 +174,8 @@ LastUpgradeCheck = 1130; TargetAttributes = { 13B07F861A680F5B00A75B9A = { + DevelopmentTeam = BXVPBATDZY; LastSwiftMigration = 1250; - DevelopmentTeam = "BXVPBATDZY"; ProvisioningStyle = Automatic; }; }; @@ -271,11 +271,17 @@ ); inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/AppAuth/AppAuthCore_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/GTMAppAuth/GTMAppAuth_Privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/GTMSessionFetcher/GTMSessionFetcher_Core_Privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/GoogleUtilities/GoogleUtilities_Privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", @@ -291,11 +297,17 @@ ); name = "[CP] Copy Pods Resources"; outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AppAuthCore_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMAppAuth_Privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMSessionFetcher_Core_Privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleUtilities_Privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", @@ -375,7 +387,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = BXVPBATDZY; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", @@ -398,9 +413,6 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; - DEVELOPMENT_TEAM = "BXVPBATDZY"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; }; name = Debug; }; @@ -411,7 +423,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = BXVPBATDZY; INFOPLIST_FILE = Sauci/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; @@ -428,9 +443,6 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; - DEVELOPMENT_TEAM = "BXVPBATDZY"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; }; name = Release; }; diff --git a/apps/mobile/ios/Sauci/PrivacyInfo.xcprivacy b/apps/mobile/ios/Sauci/PrivacyInfo.xcprivacy index 49b8ded..05d1a14 100644 --- a/apps/mobile/ios/Sauci/PrivacyInfo.xcprivacy +++ b/apps/mobile/ios/Sauci/PrivacyInfo.xcprivacy @@ -20,6 +20,7 @@ NSPrivacyAccessedAPITypeReasons CA92.1 + C56D.1 diff --git a/apps/mobile/package.json b/apps/mobile/package.json index fcade7d..5d29534 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -27,6 +27,7 @@ "expo": "~52.0.0", "expo-apple-authentication": "~7.1.3", "expo-asset": "~11.0.5", + "expo-av": "~15.0.2", "expo-blur": "~14.0.3", "expo-build-properties": "^0.13.3", "expo-clipboard": "~7.0.1", @@ -48,6 +49,7 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-native": "0.76.9", + "react-native-compressor": "^1.13.0", "react-native-gesture-handler": "~2.20.0", "react-native-purchases": "^9.6.12", "react-native-reanimated": "~3.16.0", diff --git a/apps/mobile/src/components/PackTeaser.tsx b/apps/mobile/src/components/PackTeaser.tsx index 4ff648e..0c173ae 100644 --- a/apps/mobile/src/components/PackTeaser.tsx +++ b/apps/mobile/src/components/PackTeaser.tsx @@ -121,6 +121,19 @@ export function PackTeaser({ + {/* Premium gradient background */} + + {/* Top silk highlight */} + + {/* Premium border accent */} + + - - {packIcon || "📦"} + {/* Premium label */} + PREMIUM COLLECTION + + {/* Icon with glow */} + + + + {packIcon || "📦"} + + {/* Crown badge */} + + + + {packName} + + {/* Decorative separator */} + + + + + + - Preview a few questions from this pack + Preview a few questions from this exclusive pack @@ -149,7 +185,7 @@ export function PackTeaser({ {isLoading ? ( - + Loading preview... @@ -159,12 +195,12 @@ export function PackTeaser({ - {[...Array(question.intensity)].map((_, i) => ( + {[...Array(5)].map((_, i) => ( ))} @@ -188,40 +224,39 @@ export function PackTeaser({ {questions.length > 0 && ( - + many more questions to unlock + + many more exclusive questions to unlock )} {/* Unlock Button */} - Unlock with Pro - + @@ -237,16 +272,35 @@ const styles = StyleSheet.create({ }, overlay: { ...StyleSheet.absoluteFillObject, - backgroundColor: "rgba(0, 0, 0, 0.8)", + backgroundColor: "rgba(0, 0, 0, 0.85)", }, content: { - backgroundColor: colors.backgroundLight, + backgroundColor: colors.background, borderTopLeftRadius: radius.xxl, borderTopRightRadius: radius.xxl, maxHeight: "85%", borderWidth: 1, borderBottomWidth: 0, - borderColor: colors.glass.border, + borderColor: 'rgba(212, 175, 55, 0.2)', + overflow: 'hidden', + }, + silkHighlight: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 150, + borderTopLeftRadius: radius.xxl, + borderTopRightRadius: radius.xxl, + }, + premiumBorderAccent: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 2, + backgroundColor: colors.premium.gold, + opacity: 0.4, }, scrollContent: { padding: spacing.lg, @@ -260,36 +314,98 @@ const styles = StyleSheet.create({ width: 40, height: 40, borderRadius: 20, - backgroundColor: colors.glass.background, + backgroundColor: 'rgba(255, 255, 255, 0.08)', justifyContent: "center", alignItems: "center", zIndex: 10, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', }, header: { alignItems: "center", marginBottom: spacing.xl, }, - iconContainer: { - width: 72, - height: 72, - borderRadius: 36, - backgroundColor: colors.glass.background, - justifyContent: "center", - alignItems: "center", + label: { + ...typography.caption1, + fontWeight: '600', + letterSpacing: 3, + color: colors.premium.gold, + textAlign: 'center', marginBottom: spacing.md, }, + iconWrapper: { + position: 'relative', + marginBottom: spacing.md, + }, + iconContainer: { + width: 80, + height: 80, + borderRadius: 40, + justifyContent: "center", + alignItems: "center", + overflow: 'hidden', + borderWidth: 2, + borderColor: colors.premium.gold, + shadowColor: colors.premium.gold, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.6, + shadowRadius: 20, + elevation: 12, + }, + crownBadge: { + position: 'absolute', + top: -4, + right: -4, + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: colors.background, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: colors.premium.gold, + shadowColor: colors.premium.gold, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.4, + shadowRadius: 4, + elevation: 4, + }, packEmoji: { - fontSize: 36, + fontSize: 40, }, title: { ...typography.title1, color: colors.text, marginBottom: spacing.xs, + textShadowColor: 'rgba(212, 175, 55, 0.3)', + textShadowOffset: { width: 0, height: 0 }, + textShadowRadius: 10, + }, + separator: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginVertical: spacing.md, + width: 140, + }, + separatorLine: { + flex: 1, + height: 1, + backgroundColor: 'rgba(212, 175, 55, 0.3)', + }, + separatorDiamond: { + width: 6, + height: 6, + backgroundColor: colors.premium.gold, + transform: [{ rotate: '45deg' }], + marginHorizontal: spacing.md, + opacity: 0.8, }, subtitle: { ...typography.body, color: colors.textSecondary, textAlign: "center", + lineHeight: 24, }, questionsContainer: { marginBottom: spacing.lg, @@ -305,10 +421,10 @@ const styles = StyleSheet.create({ marginTop: spacing.md, }, questionCard: { - backgroundColor: colors.glass.background, + backgroundColor: 'rgba(22, 33, 62, 0.4)', borderRadius: radius.lg, borderWidth: 1, - borderColor: colors.glass.border, + borderColor: 'rgba(212, 175, 55, 0.15)', padding: spacing.md, }, questionHeader: { @@ -319,20 +435,23 @@ const styles = StyleSheet.create({ }, intensityContainer: { flexDirection: "row", - backgroundColor: colors.primaryLight, + backgroundColor: 'rgba(212, 175, 55, 0.1)', paddingHorizontal: spacing.sm, paddingVertical: 4, borderRadius: radius.full, - gap: 1, + gap: 2, + borderWidth: 1, + borderColor: 'rgba(212, 175, 55, 0.15)', }, questionNumber: { ...typography.caption2, - color: colors.textTertiary, + color: colors.premium.champagne, + opacity: 0.7, }, questionText: { ...typography.body, color: colors.text, - lineHeight: 22, + lineHeight: 24, }, emptyContainer: { alignItems: "center", @@ -347,11 +466,19 @@ const styles = StyleSheet.create({ justifyContent: "center", alignItems: "center", marginBottom: spacing.lg, - gap: spacing.xs, + gap: spacing.sm, + backgroundColor: 'rgba(212, 175, 55, 0.08)', + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: radius.full, + alignSelf: 'center', + borderWidth: 1, + borderColor: 'rgba(212, 175, 55, 0.15)', }, moreHintText: { ...typography.caption1, - color: colors.textTertiary, + color: colors.premium.champagne, + fontWeight: '500', }, actions: { marginBottom: spacing.md, @@ -359,6 +486,11 @@ const styles = StyleSheet.create({ unlockButton: { borderRadius: radius.lg, overflow: "hidden", + shadowColor: colors.premium.gold, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, }, unlockButtonGradient: { flexDirection: "row", @@ -368,7 +500,8 @@ const styles = StyleSheet.create({ }, unlockButtonText: { ...typography.headline, - color: colors.text, + color: colors.background, + fontWeight: '700', }, }); diff --git a/apps/mobile/src/components/Paywall.tsx b/apps/mobile/src/components/Paywall.tsx index cccce3d..f83b804 100644 --- a/apps/mobile/src/components/Paywall.tsx +++ b/apps/mobile/src/components/Paywall.tsx @@ -8,6 +8,7 @@ import { ActivityIndicator, ScrollView, Platform, + Pressable, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; @@ -24,9 +25,9 @@ interface PaywallProps { const FEATURES = [ { - icon: "heart" as const, + icon: "diamond" as const, title: "Unlock All Packs", - description: "Access every question pack", + description: "Access every premium collection", }, { icon: "sparkles" as const, @@ -34,7 +35,7 @@ const FEATURES = [ description: "New packs added regularly", }, { - icon: "people" as const, + icon: "heart" as const, title: "Share with Partner", description: "Both of you get Pro access", }, @@ -125,6 +126,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) { const savings = calculateSavings(); + return ( + {/* Premium gradient background */} + + {/* Top silk highlight */} + + {/* Premium border accent */} + + - - - - Upgrade to Pro + {/* Premium label */} + EXCLUSIVE ACCESS + + {/* Icon with glow */} + + + + + + Sauci Pro + + {/* Decorative separator */} + + + + + + Unlock all premium content for you and your partner @@ -165,13 +194,13 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) { {/* Features */} - {FEATURES.map((feature) => ( + {FEATURES.map((feature, index) => ( @@ -180,6 +209,11 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) { {feature.description} + ))} @@ -188,9 +222,9 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) { {isLoadingOfferings ? ( - + - Loading subscription options... + Loading options... ) : offerings?.availablePackages ? ( @@ -200,17 +234,30 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) { const isAnnual = pkg.packageType === "ANNUAL"; return ( - setSelectedPackage(pkg)} - activeOpacity={0.7} style={[ styles.packageCard, isSelected && styles.packageCardSelected, + isAnnual && styles.packageCardAnnual, ]} > + {isAnnual && ( + + )} {isAnnual && savings && savings > 0 && ( + + SAVE {savings}% @@ -226,15 +273,28 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) { {isSelected && } - - {isAnnual ? "Annual" : "Monthly"} - - + + + {isAnnual ? "Annual" : "Monthly"} + + {isAnnual && ( + + BEST VALUE + + )} + + {formatPrice(pkg)} - + ); }) ) : ( @@ -266,7 +326,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) { {/* Purchase Button */} - {isPurchasing ? ( - + + + ) : ( - Subscribe Now + + Unlock Pro )} - + `rgba(212, 175, 55, ${opacity})`, + rose: (opacity: number) => `rgba(232, 164, 174, ${opacity})`, +}; + +// Haptics helper +const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => { + if (Platform.OS === 'web') return; + const Haptics = await import('expo-haptics'); + const feedbackStyle = style === 'light' + ? Haptics.ImpactFeedbackStyle.Light + : style === 'heavy' + ? Haptics.ImpactFeedbackStyle.Heavy + : Haptics.ImpactFeedbackStyle.Medium; + await Haptics.impactAsync(feedbackStyle); +}; + +const MAX_CARD_WIDTH = 400; +const SWIPE_THRESHOLD = 100; + +interface Props { + question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean }; + onSwipe: (direction: "left" | "right" | "up" | "down") => void; +} + +export default function SwipeCardPremium({ question, onSwipe }: Props) { + const { width: screenWidth } = useWindowDimensions(); + const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH); + + const translateX = useSharedValue(0); + const translateY = useSharedValue(0); + const rotation = useSharedValue(0); + const scale = useSharedValue(1); + const shadowExpand = useSharedValue(0); + const idleBreathing = useSharedValue(0); + const isGesturing = useSharedValue(false); + + const context = useSharedValue({ x: 0, y: 0 }); + + // Subtle idle breathing animation + useEffect(() => { + idleBreathing.value = withRepeat( + withSequence( + withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.sin) }), + withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.sin) }) + ), + -1, + true + ); + }, []); + + const gesture = Gesture.Pan() + .activeOffsetX([-15, 15]) + .activeOffsetY([-15, 15]) + .onStart(() => { + context.value = { x: translateX.value, y: translateY.value }; + scale.value = withTiming(1.02, { duration: animations.timing.fast }); + shadowExpand.value = withTiming(1, { duration: animations.timing.fast }); + isGesturing.value = true; + }) + .onUpdate((event) => { + translateX.value = event.translationX + context.value.x; + translateY.value = event.translationY + context.value.y; + rotation.value = interpolate( + translateX.value, + [-cardWidth / 2, cardWidth / 2], + [-8, 8], // Reduced rotation for elegance + Extrapolate.CLAMP + ); + }) + .onEnd(() => { + scale.value = withTiming(1, { duration: animations.timing.fast }); + shadowExpand.value = withTiming(0, { duration: animations.timing.fast }); + isGesturing.value = false; + + if (Math.abs(translateX.value) > SWIPE_THRESHOLD) { + const direction = translateX.value > 0 ? "right" : "left"; + runOnJS(triggerHaptic)('medium'); + translateX.value = withSpring( + direction === "right" ? screenWidth * 1.5 : -screenWidth * 1.5, + animations.springGentle + ); + runOnJS(onSwipe)(direction); + } else if (translateY.value < -SWIPE_THRESHOLD) { + runOnJS(triggerHaptic)('medium'); + translateY.value = withSpring(-screenWidth * 1.5, animations.springGentle); + runOnJS(onSwipe)("up"); + } else if (translateY.value > SWIPE_THRESHOLD) { + runOnJS(triggerHaptic)('light'); + translateY.value = withSpring(screenWidth * 1.5, animations.springGentle); + runOnJS(onSwipe)("down"); + } else { + translateX.value = withSpring(0, animations.spring); + translateY.value = withSpring(0, animations.spring); + rotation.value = withSpring(0, animations.spring); + } + }); + + const animatedStyle = useAnimatedStyle(() => { + const idleOffset = isGesturing.value ? 0 : interpolate(idleBreathing.value, [0, 1], [0, -4]); + return { + transform: [ + { translateX: translateX.value }, + { translateY: translateY.value + idleOffset }, + { rotate: `${rotation.value}deg` }, + { scale: scale.value }, + ], + }; + }); + + const cardShadowStyle = useAnimatedStyle(() => ({ + shadowOpacity: interpolate(shadowExpand.value, [0, 1], [0.2, 0.4]), + shadowRadius: interpolate(shadowExpand.value, [0, 1], [20, 32]), + shadowOffset: { + width: 0, + height: interpolate(shadowExpand.value, [0, 1], [10, 16]), + }, + })); + + const overlayStyle = (direction: "left" | "right" | "up" | "down") => + useAnimatedStyle(() => { + let opacity = 0; + if (direction === "right") { + opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1]); + } else if (direction === "left") { + opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 1]); + } else if (direction === "up") { + opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 1]); + } else { + opacity = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 1]); + } + return { opacity }; + }); + + const handleButtonPress = async (direction: "left" | "right" | "up") => { + await triggerHaptic('light'); + onSwipe(direction); + }; + + return ( + + + + {/* Premium dark background */} + + {/* Subtle gradient overlay */} + + + {/* Top silk highlight */} + + + {/* Swipe Overlays - Premium style */} + + + YES + + + + + NO + + + + + MAYBE + + + + + SKIP + + + + {/* Content */} + + {/* Intensity badge - more prominent */} + + INTENSITY + + {[...Array(5)].map((_, i) => ( + + ))} + + + + {/* Decorative separator */} + + + + + + + {/* Question text */} + + {question.text} + + + {/* Bottom separator */} + + + + + + + + {/* Premium Action Buttons */} + + + handleButtonPress("left")} + icon="close" + variant="no" + /> + handleButtonPress("up")} + icon="help" + variant="maybe" + small + /> + handleButtonPress("right")} + icon="heart" + variant="yes" + /> + + + + + {/* Premium border */} + + + + + ); +} + +function PremiumButton({ + onPress, + icon, + variant, + small = false, +}: { + onPress: () => void; + icon: string; + variant: 'yes' | 'no' | 'maybe'; + small?: boolean; +}) { + const buttonScale = useSharedValue(1); + + const handlePressIn = () => { + buttonScale.value = withSpring(0.92, animations.spring); + }; + + const handlePressOut = () => { + buttonScale.value = withSpring(1, animations.spring); + }; + + const buttonAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: buttonScale.value }], + })); + + const getColors = () => { + switch (variant) { + case 'yes': return { bg: rgba.gold(0.15), border: rgba.gold(0.3), icon: ACCENT }; + case 'no': return { bg: rgba.rose(0.15), border: rgba.rose(0.3), icon: ROSE }; + case 'maybe': return { bg: 'rgba(255, 255, 255, 0.08)', border: 'rgba(255, 255, 255, 0.15)', icon: colors.textSecondary }; + } + }; + + const c = getColors(); + + return ( + + + + + + ); +} + +const styles = StyleSheet.create({ + cardWrapper: { + position: "absolute", + maxWidth: MAX_CARD_WIDTH, + height: 500, + }, + cardOuter: { + width: "100%", + height: "100%", + borderRadius: radius.xl, + overflow: "hidden", + }, + cardBackground: { + flex: 1, + backgroundColor: '#0d0d1a', + borderRadius: radius.xl, + overflow: "hidden", + }, + silkHighlight: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 120, + }, + premiumBorder: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: radius.xl, + borderWidth: 1, + borderColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2 + }, + content: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: spacing.xl, + }, + intensityBadge: { + alignItems: 'center', + backgroundColor: 'rgba(212, 175, 55, 0.08)', // gold @ 0.08 + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: 'rgba(212, 175, 55, 0.15)', // gold @ 0.15 + marginBottom: spacing.md, + }, + intensityLabel: { + ...typography.caption2, + fontWeight: '600', + letterSpacing: 2, + color: 'rgba(212, 175, 55, 0.7)', // gold @ 0.7 + marginBottom: spacing.xs, + }, + intensityRow: { + flexDirection: 'row', + gap: 4, + }, + separator: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginVertical: spacing.lg, + width: 120, + }, + separatorLine: { + flex: 1, + height: 1, + backgroundColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2 + }, + separatorDiamond: { + width: 6, + height: 6, + backgroundColor: ACCENT, + transform: [{ rotate: '45deg' }], + marginHorizontal: spacing.md, + opacity: 0.5, + }, + questionText: { + ...typography.title2, + color: colors.text, + textAlign: "center", + lineHeight: 32, + paddingHorizontal: spacing.sm, + }, + footer: { + paddingBottom: spacing.xl, + paddingHorizontal: spacing.lg, + }, + buttonContainer: { + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + gap: spacing.lg, + }, + premiumButton: { + width: 60, + height: 60, + borderRadius: 30, + justifyContent: "center", + alignItems: "center", + borderWidth: 1, + }, + premiumButtonSmall: { + width: 48, + height: 48, + borderRadius: 24, + }, + // Overlays + overlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: "center", + alignItems: "center", + zIndex: 10, + borderRadius: radius.xl, + }, + overlayYes: { + backgroundColor: 'rgba(212, 175, 55, 0.15)', // gold @ 0.15 + }, + overlayNo: { + backgroundColor: 'rgba(232, 164, 174, 0.15)', // rose @ 0.15 + }, + overlayMaybe: { + backgroundColor: 'rgba(255, 255, 255, 0.08)', + }, + overlaySkip: { + backgroundColor: 'rgba(0, 0, 0, 0.3)', + }, + overlayBadgePremium: { + paddingHorizontal: spacing.xl, + paddingVertical: spacing.md, + borderRadius: radius.full, + backgroundColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2 + borderWidth: 1, + borderColor: 'rgba(212, 175, 55, 0.4)', // gold @ 0.4 + transform: [{ rotate: "-12deg" }], + }, + overlayBadgeNo: { + backgroundColor: 'rgba(232, 164, 174, 0.2)', // rose @ 0.2 + borderColor: 'rgba(232, 164, 174, 0.4)', // rose @ 0.4 + }, + overlayBadgeMaybe: { + backgroundColor: 'rgba(255, 255, 255, 0.1)', + borderColor: 'rgba(255, 255, 255, 0.2)', + }, + overlayBadgeSkip: { + backgroundColor: 'rgba(255, 255, 255, 0.1)', + borderColor: 'rgba(255, 255, 255, 0.15)', + }, + overlayTextPremium: { + fontSize: 28, + fontWeight: "600", + letterSpacing: 4, + color: ACCENT, + }, + overlayTextNo: { + color: ROSE, + }, + overlayTextMaybe: { + color: colors.textSecondary, + }, + overlayTextSkip: { + color: colors.textTertiary, + fontSize: 24, + }, +}); diff --git a/apps/mobile/src/lib/imageCache.ts b/apps/mobile/src/lib/imageCache.ts index 43073d4..b944666 100644 --- a/apps/mobile/src/lib/imageCache.ts +++ b/apps/mobile/src/lib/imageCache.ts @@ -1,4 +1,6 @@ import { supabase } from './supabase'; +import * as FileSystem from 'expo-file-system'; +import { Platform } from 'react-native'; // Cache for signed URLs with expiration tracking interface CachedUrl { @@ -8,6 +10,12 @@ interface CachedUrl { const signedUrlCache = new Map(); +// In-memory tracking of videos currently being cached to prevent duplicate downloads +const videoCacheInProgress = new Set(); + +// Video cache directory +const VIDEO_CACHE_DIR = `${FileSystem.cacheDirectory}video-cache/`; + // Buffer time before expiration to refresh URL (5 minutes) const EXPIRATION_BUFFER_MS = 5 * 60 * 1000; // Default signed URL expiry (1 hour) @@ -93,3 +101,134 @@ export async function prefetchSignedUrls( mediaPaths.map(path => getCachedSignedUrl(getStoragePath(path), bucket)) ); } + +/** + * Ensure the video cache directory exists. + */ +async function ensureVideoCacheDir(): Promise { + if (Platform.OS === 'web') return; + + const dirInfo = await FileSystem.getInfoAsync(VIDEO_CACHE_DIR); + if (!dirInfo.exists) { + await FileSystem.makeDirectoryAsync(VIDEO_CACHE_DIR, { intermediates: true }); + } +} + +/** + * Generate a safe filename from a storage path. + */ +function getVideoCacheFilename(storagePath: string): string { + // Replace slashes and other unsafe chars with underscores + return storagePath.replace(/[\/\\:*?"<>|]/g, '_'); +} + +/** + * Get the local cache path for a video. + */ +export function getVideoCachePath(storagePath: string): string { + return `${VIDEO_CACHE_DIR}${getVideoCacheFilename(storagePath)}`; +} + +/** + * Check if a video is cached locally. + * Returns the local file URI if cached, null otherwise. + */ +export async function getVideoCachedUri(storagePath: string): Promise { + if (Platform.OS === 'web') return null; + + const cachePath = getVideoCachePath(storagePath); + const fileInfo = await FileSystem.getInfoAsync(cachePath); + + if (fileInfo.exists) { + return cachePath; + } + return null; +} + +/** + * Cache a video to local storage from a signed URL. + * Returns the local file URI on success, null on failure. + */ +export async function cacheVideo( + storagePath: string, + signedUrl: string +): Promise { + if (Platform.OS === 'web') return null; + + // Check if already cached + const existingCache = await getVideoCachedUri(storagePath); + if (existingCache) { + return existingCache; + } + + // Check if caching is already in progress for this video + if (videoCacheInProgress.has(storagePath)) { + return null; + } + + try { + videoCacheInProgress.add(storagePath); + await ensureVideoCacheDir(); + + const cachePath = getVideoCachePath(storagePath); + + // Download the video file + const downloadResult = await FileSystem.downloadAsync(signedUrl, cachePath); + + if (downloadResult.status === 200) { + console.log('Video cached successfully:', storagePath); + return cachePath; + } else { + console.warn('Video cache download failed with status:', downloadResult.status); + return null; + } + } catch (error) { + console.error('Failed to cache video:', error); + return null; + } finally { + videoCacheInProgress.delete(storagePath); + } +} + +/** + * Clear all cached videos. + */ +export async function clearVideoCache(): Promise { + if (Platform.OS === 'web') return; + + try { + const dirInfo = await FileSystem.getInfoAsync(VIDEO_CACHE_DIR); + if (dirInfo.exists) { + await FileSystem.deleteAsync(VIDEO_CACHE_DIR, { idempotent: true }); + } + } catch (error) { + console.error('Failed to clear video cache:', error); + } +} + +/** + * Get the size of the video cache in bytes. + */ +export async function getVideoCacheSize(): Promise { + if (Platform.OS === 'web') return 0; + + try { + const dirInfo = await FileSystem.getInfoAsync(VIDEO_CACHE_DIR); + if (!dirInfo.exists) return 0; + + const files = await FileSystem.readDirectoryAsync(VIDEO_CACHE_DIR); + let totalSize = 0; + + for (const file of files) { + const fileInfo = await FileSystem.getInfoAsync(`${VIDEO_CACHE_DIR}${file}`); + if (fileInfo.exists && 'size' in fileInfo) { + totalSize += fileInfo.size || 0; + } + } + + return totalSize; + } catch (error) { + console.error('Failed to get video cache size:', error); + return 0; + } +} diff --git a/apps/mobile/src/store/index.ts b/apps/mobile/src/store/index.ts index d2198f8..9d1e921 100755 --- a/apps/mobile/src/store/index.ts +++ b/apps/mobile/src/store/index.ts @@ -211,8 +211,18 @@ export const useMatchStore = create((set, get) => ({ unreadCount: unreadCounts[match.id] || 0 })); - const newCount = data.filter((m) => m.is_new).length; - set({ matches: data, newMatchesCount: newCount }); + // Sort: unread messages first, then by most recent + const sortedData = data.sort((a, b) => { + // First priority: matches with unread messages + if (a.unreadCount > 0 && b.unreadCount === 0) return -1; + if (a.unreadCount === 0 && b.unreadCount > 0) return 1; + + // Second priority: most recent first + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + }); + + const newCount = sortedData.filter((m) => m.is_new).length; + set({ matches: sortedData, newMatchesCount: newCount }); }, markAsSeen: async (matchId) => { diff --git a/apps/mobile/src/theme/index.ts b/apps/mobile/src/theme/index.ts index da38ac0..8dde10e 100644 --- a/apps/mobile/src/theme/index.ts +++ b/apps/mobile/src/theme/index.ts @@ -42,13 +42,28 @@ export const colors = { // Premium/Luxury premium: { gold: '#D4AF37', + goldDark: '#B8860B', 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) + rose: '#E8A4AE', + roseDark: '#D4919B', roseLight: 'rgba(232, 164, 174, 0.15)', + roseGlow: 'rgba(232, 164, 174, 0.3)', }, + + // Swipe response overlays + overlay: { + yes: 'rgba(46, 204, 113, 0.4)', + no: 'rgba(231, 76, 60, 0.4)', + maybe: 'rgba(243, 156, 18, 0.4)', + skip: 'rgba(108, 117, 125, 0.4)', + }, + + // Muted/disabled + muted: '#6c757d', + mutedLight: 'rgba(108, 117, 125, 0.15)', }; export const gradients = { @@ -64,7 +79,7 @@ export const gradients = { error: ['#E74C3C', '#c0392b'], // Premium/Boutique gradients premiumGold: ['#D4AF37', '#B8860B'], - premiumRose: ['#B76E79', '#8B4557'], + premiumRose: ['#E8A4AE', '#D4919B'], 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 @@ -76,6 +91,55 @@ export const gradients = { boutiqueAmethyst: ['rgba(142, 68, 173, 0.4)', 'rgba(44, 62, 80, 0.4)'], }; +// Feature-specific color mapping +// Each feature has a consistent accent color for icons, buttons, and highlights +export const featureColors = { + swipe: { + accent: colors.primary, + accentDark: colors.primaryDark, + accentLight: colors.primaryLight, + accentGlow: colors.primaryGlow, + gradient: gradients.primary, + }, + match: { + accent: colors.primary, + accentDark: colors.primaryDark, + accentLight: colors.primaryLight, + accentGlow: colors.primaryGlow, + gradient: gradients.primary, + }, + quiz: { + accent: colors.premium.rose, + accentDark: colors.premium.roseDark, + accentLight: colors.premium.roseLight, + accentGlow: colors.premium.roseGlow, + gradient: gradients.premiumRose, + }, + dares: { + accent: colors.premium.gold, + accentDark: colors.premium.goldDark, + accentLight: colors.premium.goldLight, + accentGlow: colors.premium.goldGlow, + gradient: gradients.premiumGold, + }, + chat: { + accent: colors.primary, + accentDark: colors.primaryDark, + accentLight: colors.primaryLight, + accentGlow: colors.primaryGlow, + gradient: gradients.primary, + }, + profile: { + accent: colors.secondary, + accentDark: colors.secondaryDark, + accentLight: colors.secondaryLight, + accentGlow: colors.secondaryGlow, + gradient: gradients.primaryReverse, + }, +} as const; + +export type FeatureName = keyof typeof featureColors; + export const spacing = { xs: 4, sm: 8, @@ -222,6 +286,7 @@ export const animations = { export const theme = { colors, gradients, + featureColors, spacing, radius, typography, diff --git a/apps/mobile/src/types/supabase.ts b/apps/mobile/src/types/supabase.ts index 3e1bd92..443b698 100755 --- a/apps/mobile/src/types/supabase.ts +++ b/apps/mobile/src/types/supabase.ts @@ -1,489 +1,1027 @@ export type Json = - | string - | number - | boolean - | null - | { [key: string]: Json | undefined } - | Json[] + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] export type Database = { - // Allows to automatically instantiate createClient with right options - // instead of createClient(URL, KEY) - __InternalSupabase: { - PostgrestVersion: "14.1" + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "14.1" + } + public: { + Tables: { + admin_users: { + Row: { + created_at: string | null + created_by: string | null + id: string + permissions: Json | null + role: Database["public"]["Enums"]["admin_role"] + updated_at: string | null + user_id: string + } + Insert: { + created_at?: string | null + created_by?: string | null + id?: string + permissions?: Json | null + role: Database["public"]["Enums"]["admin_role"] + updated_at?: string | null + user_id: string + } + Update: { + created_at?: string | null + created_by?: string | null + id?: string + permissions?: Json | null + role?: Database["public"]["Enums"]["admin_role"] + updated_at?: string | null + user_id?: string + } + Relationships: [] + } + app_config: { + Row: { + answer_gap_threshold: number | null + id: string + updated_at: string | null + updated_by: string | null + } + Insert: { + answer_gap_threshold?: number | null + id?: string + updated_at?: string | null + updated_by?: string | null + } + Update: { + answer_gap_threshold?: number | null + id?: string + updated_at?: string | null + updated_by?: string | null + } + Relationships: [] + } + audit_logs: { + Row: { + action: Database["public"]["Enums"]["audit_action"] + admin_role: Database["public"]["Enums"]["admin_role"] + admin_user_id: string + changed_fields: string[] | null + created_at: string | null + id: string + new_values: Json | null + old_values: Json | null + record_id: string + table_name: string + } + Insert: { + action: Database["public"]["Enums"]["audit_action"] + admin_role: Database["public"]["Enums"]["admin_role"] + admin_user_id: string + changed_fields?: string[] | null + created_at?: string | null + id?: string + new_values?: Json | null + old_values?: Json | null + record_id: string + table_name: string + } + Update: { + action?: Database["public"]["Enums"]["audit_action"] + admin_role?: Database["public"]["Enums"]["admin_role"] + admin_user_id?: string + changed_fields?: string[] | null + created_at?: string | null + id?: string + new_values?: Json | null + old_values?: Json | null + record_id?: string + table_name?: string + } + Relationships: [] + } + categories: { + Row: { + created_at: string | null + description: string | null + icon: string | null + id: string + name: string + sort_order: number | null + } + Insert: { + created_at?: string | null + description?: string | null + icon?: string | null + id?: string + name: string + sort_order?: number | null + } + Update: { + created_at?: string | null + description?: string | null + icon?: string | null + id?: string + name?: string + sort_order?: number | null + } + Relationships: [] + } + code_redemptions: { + Row: { + code_id: string + id: string + redeemed_at: string | null + user_id: string + } + Insert: { + code_id: string + id?: string + redeemed_at?: string | null + user_id: string + } + Update: { + code_id?: string + id?: string + redeemed_at?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "code_redemptions_code_id_fkey" + columns: ["code_id"] + isOneToOne: false + referencedRelation: "redemption_codes" + referencedColumns: ["id"] + }, + ] + } + couple_packs: { + Row: { + couple_id: string + created_at: string | null + enabled: boolean | null + pack_id: string + } + Insert: { + couple_id: string + created_at?: string | null + enabled?: boolean | null + pack_id: string + } + Update: { + couple_id?: string + created_at?: string | null + enabled?: boolean | null + pack_id?: string + } + Relationships: [ + { + foreignKeyName: "couple_packs_couple_id_fkey" + columns: ["couple_id"] + isOneToOne: false + referencedRelation: "couples" + referencedColumns: ["id"] + }, + { + foreignKeyName: "couple_packs_pack_id_fkey" + columns: ["pack_id"] + isOneToOne: false + referencedRelation: "question_packs" + referencedColumns: ["id"] + }, + ] + } + couples: { + Row: { + created_at: string | null + id: string + invite_code: string | null + } + Insert: { + created_at?: string | null + id?: string + invite_code?: string | null + } + Update: { + created_at?: string | null + id?: string + invite_code?: string | null + } + Relationships: [] + } + feature_interests: { + Row: { + created_at: string + feature_name: string + id: string + user_id: string + } + Insert: { + created_at?: string + feature_name: string + id?: string + user_id: string + } + Update: { + created_at?: string + feature_name?: string + id?: string + user_id?: string + } + Relationships: [] + } + feedback: { + Row: { + admin_notes: string | null + created_at: string | null + description: string + device_info: Json + id: string + question_id: string | null + screenshot_url: string | null + status: Database["public"]["Enums"]["feedback_status"] + title: string + type: Database["public"]["Enums"]["feedback_type"] + updated_at: string | null + user_id: string + } + Insert: { + admin_notes?: string | null + created_at?: string | null + description: string + device_info?: Json + id?: string + question_id?: string | null + screenshot_url?: string | null + status?: Database["public"]["Enums"]["feedback_status"] + title: string + type: Database["public"]["Enums"]["feedback_type"] + updated_at?: string | null + user_id: string + } + Update: { + admin_notes?: string | null + created_at?: string | null + description?: string + device_info?: Json + id?: string + question_id?: string | null + screenshot_url?: string | null + status?: Database["public"]["Enums"]["feedback_status"] + title?: string + type?: Database["public"]["Enums"]["feedback_type"] + updated_at?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "feedback_question_id_fkey" + columns: ["question_id"] + isOneToOne: false + referencedRelation: "questions" + referencedColumns: ["id"] + }, + { + foreignKeyName: "feedback_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } + matches: { + Row: { + couple_id: string + created_at: string | null + id: string + is_new: boolean | null + match_type: Database["public"]["Enums"]["match_type"] + question_id: string + } + Insert: { + couple_id: string + created_at?: string | null + id?: string + is_new?: boolean | null + match_type: Database["public"]["Enums"]["match_type"] + question_id: string + } + Update: { + couple_id?: string + created_at?: string | null + id?: string + is_new?: boolean | null + match_type?: Database["public"]["Enums"]["match_type"] + question_id?: string + } + Relationships: [ + { + foreignKeyName: "matches_couple_id_fkey" + columns: ["couple_id"] + isOneToOne: false + referencedRelation: "couples" + referencedColumns: ["id"] + }, + { + foreignKeyName: "matches_question_id_fkey" + columns: ["question_id"] + isOneToOne: false + referencedRelation: "questions" + referencedColumns: ["id"] + }, + ] + } + messages: { + Row: { + content: string | null + created_at: string | null + delivered_at: string | null + id: string + match_id: string + media_expired: boolean | null + media_expires_at: string | null + media_path: string | null + media_type: string | null + media_viewed_at: string | null + read_at: string | null + user_id: string + } + Insert: { + content?: string | null + created_at?: string | null + delivered_at?: string | null + id?: string + match_id: string + media_expired?: boolean | null + media_expires_at?: string | null + media_path?: string | null + media_type?: string | null + media_viewed_at?: string | null + read_at?: string | null + user_id: string + } + Update: { + content?: string | null + created_at?: string | null + delivered_at?: string | null + id?: string + match_id?: string + media_expired?: boolean | null + media_expires_at?: string | null + media_path?: string | null + media_type?: string | null + media_viewed_at?: string | null + read_at?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "messages_match_id_fkey" + columns: ["match_id"] + isOneToOne: false + referencedRelation: "matches" + referencedColumns: ["id"] + }, + ] + } + pack_topics: { + Row: { + pack_id: string + topic_id: string + } + Insert: { + pack_id: string + topic_id: string + } + Update: { + pack_id?: string + topic_id?: string + } + Relationships: [ + { + foreignKeyName: "pack_topics_pack_id_fkey" + columns: ["pack_id"] + isOneToOne: false + referencedRelation: "question_packs" + referencedColumns: ["id"] + }, + { + foreignKeyName: "pack_topics_topic_id_fkey" + columns: ["topic_id"] + isOneToOne: false + referencedRelation: "topics" + referencedColumns: ["id"] + }, + ] + } + profiles: { + Row: { + avatar_url: string | null + couple_id: string | null + created_at: string | null + email: string | null + gender: string | null + id: string + is_premium: boolean | null + name: string | null + onboarding_completed: boolean | null + push_token: string | null + show_explicit_content: boolean | null + updated_at: string | null + usage_reason: string | null + } + Insert: { + avatar_url?: string | null + couple_id?: string | null + created_at?: string | null + email?: string | null + gender?: string | null + id: string + is_premium?: boolean | null + name?: string | null + onboarding_completed?: boolean | null + push_token?: string | null + show_explicit_content?: boolean | null + updated_at?: string | null + usage_reason?: string | null + } + Update: { + avatar_url?: string | null + couple_id?: string | null + created_at?: string | null + email?: string | null + gender?: string | null + id?: string + is_premium?: boolean | null + name?: string | null + onboarding_completed?: boolean | null + push_token?: string | null + show_explicit_content?: boolean | null + updated_at?: string | null + usage_reason?: string | null + } + Relationships: [ + { + foreignKeyName: "profiles_couple_id_fkey" + columns: ["couple_id"] + isOneToOne: false + referencedRelation: "couples" + referencedColumns: ["id"] + }, + ] + } + question_packs: { + Row: { + category_id: string | null + created_at: string | null + description: string | null + icon: string | null + id: string + is_explicit: boolean + is_premium: boolean | null + is_public: boolean | null + name: string + sort_order: number | null + } + Insert: { + category_id?: string | null + created_at?: string | null + description?: string | null + icon?: string | null + id?: string + is_explicit?: boolean + is_premium?: boolean | null + is_public?: boolean | null + name: string + sort_order?: number | null + } + Update: { + category_id?: string | null + created_at?: string | null + description?: string | null + icon?: string | null + id?: string + is_explicit?: boolean + is_premium?: boolean | null + is_public?: boolean | null + name?: string + sort_order?: number | null + } + Relationships: [ + { + foreignKeyName: "question_packs_category_id_fkey" + columns: ["category_id"] + isOneToOne: false + referencedRelation: "categories" + referencedColumns: ["id"] + }, + ] + } + questions: { + Row: { + allowed_couple_genders: string[] | null + created_at: string | null + id: string + intensity: number | null + pack_id: string + partner_text: string | null + target_user_genders: string[] | null + text: string + } + Insert: { + allowed_couple_genders?: string[] | null + created_at?: string | null + id?: string + intensity?: number | null + pack_id: string + partner_text?: string | null + target_user_genders?: string[] | null + text: string + } + Update: { + allowed_couple_genders?: string[] | null + created_at?: string | null + id?: string + intensity?: number | null + pack_id?: string + partner_text?: string | null + target_user_genders?: string[] | null + text?: string + } + Relationships: [ + { + foreignKeyName: "questions_pack_id_fkey" + columns: ["pack_id"] + isOneToOne: false + referencedRelation: "question_packs" + referencedColumns: ["id"] + }, + ] + } + redemption_codes: { + Row: { + code: string + created_at: string | null + created_by: string | null + current_uses: number | null + description: string | null + expires_at: string | null + id: string + is_active: boolean | null + max_uses: number | null + updated_at: string | null + } + Insert: { + code: string + created_at?: string | null + created_by?: string | null + current_uses?: number | null + description?: string | null + expires_at?: string | null + id?: string + is_active?: boolean | null + max_uses?: number | null + updated_at?: string | null + } + Update: { + code?: string + created_at?: string | null + created_by?: string | null + current_uses?: number | null + description?: string | null + expires_at?: string | null + id?: string + is_active?: boolean | null + max_uses?: number | null + updated_at?: string | null + } + Relationships: [] + } + responses: { + Row: { + answer: Database["public"]["Enums"]["answer_type"] + couple_id: string + created_at: string | null + id: string + question_id: string + user_id: string + } + Insert: { + answer: Database["public"]["Enums"]["answer_type"] + couple_id: string + created_at?: string | null + id?: string + question_id: string + user_id: string + } + Update: { + answer?: Database["public"]["Enums"]["answer_type"] + couple_id?: string + created_at?: string | null + id?: string + question_id?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "responses_couple_id_fkey" + columns: ["couple_id"] + isOneToOne: false + referencedRelation: "couples" + referencedColumns: ["id"] + }, + { + foreignKeyName: "responses_question_id_fkey" + columns: ["question_id"] + isOneToOne: false + referencedRelation: "questions" + referencedColumns: ["id"] + }, + { + foreignKeyName: "responses_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } + revenuecat_webhook_events: { + Row: { + app_user_id: string + event_id: string + event_type: string + id: string + payload: Json | null + processed_at: string | null + } + Insert: { + app_user_id: string + event_id: string + event_type: string + id?: string + payload?: Json | null + processed_at?: string | null + } + Update: { + app_user_id?: string + event_id?: string + event_type?: string + id?: string + payload?: Json | null + processed_at?: string | null + } + Relationships: [] + } + subscriptions: { + Row: { + cancel_reason: string | null + created_at: string | null + entitlement_ids: string[] | null + expires_at: string | null + grace_period_expires_at: string | null + id: string + is_sandbox: boolean | null + original_transaction_id: string | null + product_id: string + purchased_at: string + revenuecat_app_user_id: string + status: Database["public"]["Enums"]["subscription_status"] + store: string | null + updated_at: string | null + user_id: string + } + Insert: { + cancel_reason?: string | null + created_at?: string | null + entitlement_ids?: string[] | null + expires_at?: string | null + grace_period_expires_at?: string | null + id?: string + is_sandbox?: boolean | null + original_transaction_id?: string | null + product_id: string + purchased_at: string + revenuecat_app_user_id: string + status?: Database["public"]["Enums"]["subscription_status"] + store?: string | null + updated_at?: string | null + user_id: string + } + Update: { + cancel_reason?: string | null + created_at?: string | null + entitlement_ids?: string[] | null + expires_at?: string | null + grace_period_expires_at?: string | null + id?: string + is_sandbox?: boolean | null + original_transaction_id?: string | null + product_id?: string + purchased_at?: string + revenuecat_app_user_id?: string + status?: Database["public"]["Enums"]["subscription_status"] + store?: string | null + updated_at?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "subscriptions_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } + topics: { + Row: { + created_at: string | null + description: string | null + icon: string | null + id: string + name: string + sort_order: number | null + } + Insert: { + created_at?: string | null + description?: string | null + icon?: string | null + id?: string + name: string + sort_order?: number | null + } + Update: { + created_at?: string | null + description?: string | null + icon?: string | null + id?: string + name?: string + sort_order?: number | null + } + Relationships: [] + } } - public: { - Tables: { - categories: { - Row: { - id: string - name: string - description: string | null - icon: string | null - sort_order: number | null - created_at: string | null - } - Insert: { - id?: string - name: string - description?: string | null - icon?: string | null - sort_order?: number | null - created_at?: string | null - } - Update: { - id?: string - name?: string - description?: string | null - icon?: string | null - sort_order?: number | null - created_at?: string | null - } - Relationships: [] - } - couple_packs: { - Row: { - couple_id: string - created_at: string | null - enabled: boolean | null - pack_id: string - } - Insert: { - couple_id: string - created_at?: string | null - enabled?: boolean | null - pack_id: string - } - Update: { - couple_id?: string - created_at?: string | null - enabled?: boolean | null - pack_id?: string - } - Relationships: [ - { - foreignKeyName: "couple_packs_couple_id_fkey" - columns: ["couple_id"] - isOneToOne: false - referencedRelation: "couples" - referencedColumns: ["id"] - }, - { - foreignKeyName: "couple_packs_pack_id_fkey" - columns: ["pack_id"] - isOneToOne: false - referencedRelation: "question_packs" - referencedColumns: ["id"] - }, - ] - } - couples: { - Row: { - created_at: string | null - id: string - invite_code: string | null - } - Insert: { - created_at?: string | null - id?: string - invite_code?: string | null - } - Update: { - created_at?: string | null - id?: string - invite_code?: string | null - } - Relationships: [] - } - matches: { - Row: { - couple_id: string - created_at: string | null - id: string - is_new: boolean | null - match_type: Database["public"]["Enums"]["match_type"] - question_id: string - } - Insert: { - couple_id: string - created_at?: string | null - id?: string - is_new?: boolean | null - match_type: Database["public"]["Enums"]["match_type"] - question_id: string - } - Update: { - couple_id?: string - created_at?: string | null - id?: string - is_new?: boolean | null - match_type?: Database["public"]["Enums"]["match_type"] - question_id?: string - } - Relationships: [ - { - foreignKeyName: "matches_couple_id_fkey" - columns: ["couple_id"] - isOneToOne: false - referencedRelation: "couples" - referencedColumns: ["id"] - }, - { - foreignKeyName: "matches_question_id_fkey" - columns: ["question_id"] - isOneToOne: false - referencedRelation: "questions" - referencedColumns: ["id"] - }, - ] - } - messages: { - Row: { - content: string | null - created_at: string | null - delivered_at: string | null - id: string - match_id: string - media_path: string | null - media_viewed_at: string | null - read_at: string | null - user_id: string - } - Insert: { - content?: string | null - created_at?: string | null - delivered_at?: string | null - id?: string - match_id: string - media_path?: string | null - media_viewed_at?: string | null - read_at?: string | null - user_id: string - } - Update: { - content?: string | null - created_at?: string | null - delivered_at?: string | null - id?: string - match_id?: string - media_path?: string | null - media_viewed_at?: string | null - read_at?: string | null - user_id?: string - } - Relationships: [ - { - foreignKeyName: "messages_match_id_fkey" - columns: ["match_id"] - isOneToOne: false - referencedRelation: "matches" - referencedColumns: ["id"] - }, - ] - } - profiles: { - Row: { - avatar_url: string | null - couple_id: string | null - created_at: string | null - email: string | null - id: string - is_premium: boolean | null - name: string | null - push_token: string | null - updated_at: string | null - } - Insert: { - avatar_url?: string | null - couple_id?: string | null - created_at?: string | null - email?: string | null - id: string - is_premium?: boolean | null - name?: string | null - push_token?: string | null - updated_at?: string | null - } - Update: { - avatar_url?: string | null - couple_id?: string | null - created_at?: string | null - email?: string | null - id?: string - is_premium?: boolean | null - name?: string | null - push_token?: string | null - updated_at?: string | null - } - Relationships: [ - { - foreignKeyName: "profiles_couple_id_fkey" - columns: ["couple_id"] - isOneToOne: false - referencedRelation: "couples" - referencedColumns: ["id"] - }, - ] - } - question_packs: { - Row: { - id: string - name: string - description: string | null - icon: string | null - is_premium: boolean | null - is_public: boolean | null - sort_order: number | null - category_id: string | null - created_at: string | null - } - Insert: { - id?: string - name: string - description?: string | null - icon?: string | null - is_premium?: boolean | null - is_public?: boolean | null - sort_order?: number | null - category_id?: string | null - created_at?: string | null - } - Update: { - id?: string - name?: string - description?: string | null - icon?: string | null - is_premium?: boolean | null - is_public?: boolean | null - sort_order?: number | null - category_id?: string | null - created_at?: string | null - } - Relationships: [ - { - foreignKeyName: "question_packs_category_id_fkey" - columns: ["category_id"] - isOneToOne: false - referencedRelation: "categories" - referencedColumns: ["id"] - } - ] - } - questions: { - Row: { - created_at: string | null - id: string - intensity: number | null - pack_id: string - text: string - partner_text: string | null - } - Insert: { - created_at?: string | null - id?: string - intensity?: number | null - pack_id: string - text: string - partner_text?: string | null - } - Update: { - created_at?: string | null - id?: string - intensity?: number | null - pack_id?: string - text?: string - partner_text?: string | null - } - Relationships: [ - { - foreignKeyName: "questions_pack_id_fkey" - columns: ["pack_id"] - isOneToOne: false - referencedRelation: "question_packs" - referencedColumns: ["id"] - }, - ] - } - responses: { - Row: { - answer: Database["public"]["Enums"]["answer_type"] - couple_id: string - created_at: string | null - id: string - question_id: string - user_id: string - } - Insert: { - answer: Database["public"]["Enums"]["answer_type"] - couple_id: string - created_at?: string | null - id?: string - question_id: string - user_id: string - } - Update: { - answer?: Database["public"]["Enums"]["answer_type"] - couple_id?: string - created_at?: string | null - id?: string - question_id?: string - user_id?: string - } - Relationships: [ - { - foreignKeyName: "responses_couple_id_fkey" - columns: ["couple_id"] - isOneToOne: false - referencedRelation: "couples" - referencedColumns: ["id"] - }, - { - foreignKeyName: "responses_question_id_fkey" - columns: ["question_id"] - isOneToOne: false - referencedRelation: "questions" - referencedColumns: ["id"] - }, - ] - } - } - Views: { - [_ in never]: never - } - Functions: { - get_recommended_questions: { - Args: { - target_pack_id?: string - } - Returns: { - id: string - text: string - partner_text: string | null - is_two_part: boolean - pack_id: string - intensity: number - partner_answered: boolean - }[] - } - } - Enums: { - answer_type: "yes" | "no" | "maybe" - match_type: "yes_yes" | "yes_maybe" | "maybe_maybe" - } - CompositeTypes: { - [_ in never]: never - } + Views: { + [_ in never]: never } + Functions: { + cleanup_expired_videos: { + Args: Record + Returns: { + deleted_count: number + deleted_paths: string[] + }[] + } + get_admin_role: { + Args: { check_user_id?: string } + Returns: Database["public"]["Enums"]["admin_role"] + } + get_answer_gap_status: { + Args: Record + Returns: { + is_blocked: boolean + threshold: number + unanswered_by_partner: number + }[] + } + get_auth_user_couple_id: { Args: Record; Returns: string } + get_pack_teaser_questions: { + Args: { target_pack_id: string } + Returns: { + id: string + intensity: number + text: string + }[] + } + get_profiles_with_auth_info: { + Args: Record + Returns: { + avatar_url: string + couple_id: string + created_at: string + email: string + email_confirmed_at: string + id: string + is_premium: boolean + last_sign_in_at: string + name: string + }[] + } + get_recommended_questions: { + Args: { target_pack_id?: string } + Returns: { + allowed_couple_genders: string[] + id: string + intensity: number + is_two_part: boolean + pack_id: string + partner_answered: boolean + partner_text: string + target_user_genders: string[] + text: string + }[] + } + get_user_media_files: { + Args: { user_id: string } + Returns: { + bucket_id: string + created_at: string + id: string + metadata: Json + name: string + }[] + } + get_user_storage_usage: { + Args: Record + Returns: { + owner: string + total_bytes: number + }[] + } + has_premium_access: { Args: { check_user_id: string }; Returns: boolean } + is_admin: { Args: { check_user_id?: string }; Returns: boolean } + is_super_admin: { Args: { check_user_id?: string }; Returns: boolean } + log_admin_action: { + Args: { + p_action: Database["public"]["Enums"]["audit_action"] + p_new_values?: Json + p_old_values?: Json + p_record_id: string + p_table_name: string + } + Returns: string + } + redeem_code_by_email: { + Args: { p_code: string; p_email: string } + Returns: Json + } + } + Enums: { + admin_role: "pack_creator" | "super_admin" + answer_type: "yes" | "no" | "maybe" + audit_action: "INSERT" | "UPDATE" | "DELETE" + feedback_status: + | "new" + | "reviewed" + | "in_progress" + | "resolved" + | "closed" + feedback_type: "bug" | "feature_request" | "general" | "question" + match_type: "yes_yes" | "yes_maybe" | "maybe_maybe" + subscription_status: + | "active" + | "cancelled" + | "expired" + | "billing_issue" + | "paused" + } + CompositeTypes: { + [_ in never]: never + } + } } -type PublicSchema = Database[Extract] +type DatabaseWithoutInternals = Omit -// Helper type to get schema keys excluding internal keys -type SchemaName = Exclude - -// Helper to safely get schema -type GetSchema = Database[S] +type DefaultSchema = DatabaseWithoutInternals[Extract] export type Tables< - PublicTableNameOrOptions extends - | keyof (PublicSchema["Tables"] & PublicSchema["Views"]) - | { schema: SchemaName }, - TableName extends PublicTableNameOrOptions extends { schema: SchemaName } - ? keyof (GetSchema["Tables"] & - GetSchema["Views"]) + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) : never = never, -> = PublicTableNameOrOptions extends { schema: SchemaName } - ? (GetSchema["Tables"] & - GetSchema["Views"])[TableName] extends { - Row: infer R - } - ? R - : never - : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] & - PublicSchema["Views"]) - ? (PublicSchema["Tables"] & - PublicSchema["Views"])[PublicTableNameOrOptions] extends { - Row: infer R - } +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } ? R : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never : never export type TablesInsert< - PublicTableNameOrOptions extends - | keyof PublicSchema["Tables"] - | { schema: SchemaName }, - TableName extends PublicTableNameOrOptions extends { schema: SchemaName } - ? keyof GetSchema["Tables"] + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never = never, -> = PublicTableNameOrOptions extends { schema: SchemaName } - ? GetSchema["Tables"][TableName] extends { - Insert: infer I +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I } ? I : never - : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] - ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { Insert: infer I - } - ? I - : never + } + ? I + : never : never export type TablesUpdate< - PublicTableNameOrOptions extends - | keyof PublicSchema["Tables"] - | { schema: SchemaName }, - TableName extends PublicTableNameOrOptions extends { schema: SchemaName } - ? keyof GetSchema["Tables"] + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never = never, -> = PublicTableNameOrOptions extends { schema: SchemaName } - ? GetSchema["Tables"][TableName] extends { - Update: infer U +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U } ? U : never - : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] - ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { Update: infer U - } - ? U - : never + } + ? U + : never : never export type Enums< - PublicEnumNameOrOptions extends - | keyof PublicSchema["Enums"] - | { schema: SchemaName }, - EnumName extends PublicEnumNameOrOptions extends { schema: SchemaName } - ? keyof GetSchema["Enums"] + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] : never = never, -> = PublicEnumNameOrOptions extends { schema: SchemaName } - ? GetSchema["Enums"][EnumName] - : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] - ? PublicSchema["Enums"][PublicEnumNameOrOptions] +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] : never export type CompositeTypes< - PublicCompositeTypeNameOrOptions extends - | keyof PublicSchema["CompositeTypes"] - | { schema: SchemaName }, - CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { - schema: SchemaName - } - ? keyof GetSchema["CompositeTypes"] + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] : never = never, -> = PublicCompositeTypeNameOrOptions extends { schema: SchemaName } - ? GetSchema["CompositeTypes"][CompositeTypeName] - : PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"] - ? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] : never export const Constants = { - public: { - Enums: { - answer_type: ["yes", "no", "maybe"], - match_type: ["yes_yes", "yes_maybe", "maybe_maybe"], - }, + public: { + Enums: { + admin_role: ["pack_creator", "super_admin"], + answer_type: ["yes", "no", "maybe"], + audit_action: ["INSERT", "UPDATE", "DELETE"], + feedback_status: ["new", "reviewed", "in_progress", "resolved", "closed"], + feedback_type: ["bug", "feature_request", "general", "question"], + match_type: ["yes_yes", "yes_maybe", "maybe_maybe"], + subscription_status: [ + "active", + "cancelled", + "expired", + "billing_issue", + "paused", + ], }, + }, } as const diff --git a/apps/supabase/functions/cleanup-expired-videos/index.ts b/apps/supabase/functions/cleanup-expired-videos/index.ts new file mode 100644 index 0000000..e1d40f5 --- /dev/null +++ b/apps/supabase/functions/cleanup-expired-videos/index.ts @@ -0,0 +1,91 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "jsr:@supabase/supabase-js@2"; + +/** + * Cleanup expired videos from storage. + * This function is called by pg_cron daily at 3 AM UTC. + * It finds videos where media_expires_at has passed, deletes the files from storage, + * and marks the messages as expired. + */ +Deno.serve(async (req) => { + // Only allow POST requests (from cron) or GET (for manual testing) + if (req.method !== "POST" && req.method !== "GET") { + return new Response("Method not allowed", { status: 405 }); + } + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! + ); + + try { + // Get expired videos that still have files + const { data: expiredVideos, error: fetchError } = await supabase + .from("messages") + .select("id, media_path") + .eq("media_type", "video") + .eq("media_expired", false) + .not("media_path", "is", null) + .lt("media_expires_at", new Date().toISOString()); + + if (fetchError) { + console.error("Error fetching expired videos:", fetchError); + return new Response( + JSON.stringify({ error: fetchError.message }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + if (!expiredVideos || expiredVideos.length === 0) { + return new Response( + JSON.stringify({ deleted: 0, message: "No expired videos found" }), + { headers: { "Content-Type": "application/json" } } + ); + } + + // Delete files from storage + const paths = expiredVideos.map((v) => v.media_path).filter(Boolean) as string[]; + + if (paths.length > 0) { + const { error: storageError } = await supabase.storage + .from("chat-media") + .remove(paths); + + if (storageError) { + console.error("Error deleting from storage:", storageError); + // Continue anyway to mark as expired - files may already be deleted + } + } + + // Mark messages as expired + const ids = expiredVideos.map((v) => v.id); + const { error: updateError } = await supabase + .from("messages") + .update({ media_expired: true, media_path: null }) + .in("id", ids); + + if (updateError) { + console.error("Error marking messages as expired:", updateError); + return new Response( + JSON.stringify({ error: updateError.message }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + console.log(`Cleaned up ${expiredVideos.length} expired videos`); + + return new Response( + JSON.stringify({ + deleted: expiredVideos.length, + paths: paths, + }), + { headers: { "Content-Type": "application/json" } } + ); + } catch (err) { + console.error("Unexpected error:", err); + return new Response( + JSON.stringify({ error: "Internal server error" }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +}); diff --git a/apps/supabase/migrations/20260101100000_add_video_support.sql b/apps/supabase/migrations/20260101100000_add_video_support.sql new file mode 100644 index 0000000..4ef418d --- /dev/null +++ b/apps/supabase/migrations/20260101100000_add_video_support.sql @@ -0,0 +1,59 @@ +-- Add video support to chat messages with 30-day retention policy +-- Videos expire 30 days after being viewed by the recipient + +-- Add media type to distinguish images from videos +ALTER TABLE messages ADD COLUMN media_type TEXT CHECK (media_type IN ('image', 'video')); + +-- Track when media expires (set when video is viewed: media_viewed_at + 30 days) +ALTER TABLE messages ADD COLUMN media_expires_at TIMESTAMPTZ; + +-- Track if media was deleted due to expiration +ALTER TABLE messages ADD COLUMN media_expired BOOLEAN DEFAULT false; + +-- Backfill existing media as images +UPDATE messages SET media_type = 'image' WHERE media_path IS NOT NULL; + +-- Enable pg_cron extension for scheduled cleanup +CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA extensions; + +-- Create cleanup function that handles both DB updates and storage deletion tracking +CREATE OR REPLACE FUNCTION cleanup_expired_videos() +RETURNS TABLE(deleted_count int, deleted_paths text[]) AS $$ +DECLARE + expired_paths text[]; + count int; +BEGIN + -- Get paths of expired videos + SELECT array_agg(media_path) + INTO expired_paths + FROM messages + WHERE media_type = 'video' + AND media_expires_at IS NOT NULL + AND media_expires_at < NOW() + AND media_expired = false + AND media_path IS NOT NULL; + + -- Mark as expired and clear media_path + WITH updated AS ( + UPDATE messages + SET media_expired = true, media_path = NULL + WHERE media_type = 'video' + AND media_expires_at IS NOT NULL + AND media_expires_at < NOW() + AND media_expired = false + AND media_path IS NOT NULL + RETURNING 1 + ) + SELECT count(*) INTO count FROM updated; + + RETURN QUERY SELECT count, COALESCE(expired_paths, ARRAY[]::text[]); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Schedule daily cleanup at 3 AM UTC +-- The database function marks videos as expired, edge function handles storage deletion +SELECT cron.schedule( + 'cleanup-expired-videos', + '0 3 * * *', + 'SELECT * FROM cleanup_expired_videos()' +); diff --git a/package-lock.json b/package-lock.json index 96ecf2c..309cf3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,7 @@ "expo": "~52.0.0", "expo-apple-authentication": "~7.1.3", "expo-asset": "~11.0.5", + "expo-av": "~15.0.2", "expo-blur": "~14.0.3", "expo-build-properties": "^0.13.3", "expo-clipboard": "~7.0.1", @@ -101,6 +102,7 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-native": "0.76.9", + "react-native-compressor": "^1.13.0", "react-native-gesture-handler": "~2.20.0", "react-native-purchases": "^9.6.12", "react-native-reanimated": "~3.16.0", @@ -10140,6 +10142,23 @@ "react-native": "*" } }, + "node_modules/expo-av": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/expo-av/-/expo-av-15.0.2.tgz", + "integrity": "sha512-AHIHXdqLgK1dfHZF0JzX3YSVySGMrWn9QtPzaVjw54FAzvXfMt4sIoq4qRL/9XWCP9+ICcCs/u3EcvmxQjrfcA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-blur": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-14.0.3.tgz", @@ -14730,6 +14749,19 @@ } } }, + "node_modules/react-native-compressor": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/react-native-compressor/-/react-native-compressor-1.13.0.tgz", + "integrity": "sha512-vJYbrcjz2S7kgE3Q7444F71CjEDq5Qg6sGn67N9fJ0I1K6EhB/ZxpULdDe3FxJ4h/ncJc0oxOzNWZhxtGTqcQw==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-gesture-handler": { "version": "2.20.2", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz",