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
);
@@ -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",