Update mobile app UI with premium design system

- Add design guidelines to CLAUDE.md
- Redesign navigation with boutique styling
- Add video support to chat with compression
- Update matches/dares/quiz screens with gold/rose accents
- Improve packs browsing with category tabs
- Add SwipeCardPremium component
- Update theme with premium color palette
- Add video cleanup edge function and migration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 4.5 committed 2026-01-01 20:26:33 +00:00
1 parent feb7657562
commit cac7a2a31d
31 files changed
+6123 -1312

No files matched your search

+8
View File
@@ -2,6 +2,14 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 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 ## 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. 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.
+354
View File
@@ -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,
};
```
+123 -43
View File
@@ -44,13 +44,28 @@ interface RadialMenuItem {
icon: keyof typeof Ionicons.glyphMap; icon: keyof typeof Ionicons.glyphMap;
label: string; label: string;
angle: number; // degrees from top (0 = top, -45 = left, 45 = right) 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[] = [ const RADIAL_MENU_ITEMS: RadialMenuItem[] = [
{ id: 'dares', icon: 'flash', label: 'Dares', angle: -50, color: '#FF6B35' }, { id: 'dares', icon: 'flash', label: 'Dares', angle: -50, variant: 'gold' },
{ id: 'match', icon: 'flame', label: 'Match', angle: 0 }, { id: 'match', icon: 'flame', label: 'Match', angle: 0, variant: 'primary' },
{ id: 'quiz', icon: 'help-circle', label: 'Quiz', angle: 50, color: '#7B68EE' }, { id: 'quiz', icon: 'help-circle', label: 'Quiz', angle: 50, variant: 'rose' },
]; ];
const RADIAL_DISTANCE = 100; // Distance from center button const RADIAL_DISTANCE = 100; // Distance from center button
@@ -73,24 +88,26 @@ function PlayTabButton({ accessibilityState }: PlayTabButtonProps) {
return ( return (
<View style={styles.playButtonContainer}> <View style={styles.playButtonContainer}>
{/* Outer glow effect */} {/* Outer glow effect - primary brand */}
<View style={[styles.playButtonGlow, isSelected && styles.playButtonGlowActive]} /> <View style={[styles.playButtonGlow, isSelected && styles.playButtonGlowActive]} />
{/* Main circle button */} {/* Main circle button - premium dark with primary gradient border */}
<Pressable onPress={() => globalMenuToggle?.()}> <Pressable onPress={() => globalMenuToggle?.()}>
<Animated.View style={styles.playButtonCircle}> <Animated.View style={styles.playButtonCircle}>
<View style={styles.playButtonInner}>
{/* Silk highlight at top - primary gradient */}
<LinearGradient <LinearGradient
colors={gradients.primary as [string, string]} colors={[`${NAV_COLORS.primaryRgba}0.2)`, `${NAV_COLORS.secondaryRgba}0.1)`, 'transparent']}
style={styles.playButtonHighlight}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
style={styles.playButtonGradient} />
>
<Ionicons <Ionicons
name={globalMenuOpen ? "close" : "flame"} name={globalMenuOpen ? "close" : "flame"}
size={32} size={28}
color={colors.text} color={globalMenuOpen ? colors.textSecondary : NAV_COLORS.primary}
/> />
</LinearGradient> </View>
</Animated.View> </Animated.View>
</Pressable> </Pressable>
</View> </View>
@@ -524,8 +541,8 @@ export default function AppLayout() {
tabBarBackground: () => <TabBarBackground />, tabBarBackground: () => <TabBarBackground />,
tabBarStyle: shouldHideTabBar ? { display: 'none' } : { tabBarStyle: shouldHideTabBar ? { display: 'none' } : {
position: 'absolute', position: 'absolute',
backgroundColor: 'rgba(18, 18, 18, 0.85)', backgroundColor: 'rgba(13, 13, 26, 0.92)',
borderTopColor: colors.glass.border, borderTopColor: 'rgba(233, 69, 96, 0.2)', // primary brand color
borderTopWidth: 1, borderTopWidth: 1,
paddingTop: 8, paddingTop: 8,
paddingBottom: Platform.OS === 'ios' ? 28 : 8, paddingBottom: Platform.OS === 'ios' ? 28 : 8,
@@ -533,10 +550,11 @@ export default function AppLayout() {
elevation: 0, elevation: 0,
}, },
tabBarActiveTintColor: colors.primary, tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: '#fff', tabBarInactiveTintColor: colors.textSecondary,
tabBarLabelStyle: { tabBarLabelStyle: {
fontSize: 11, fontSize: 10,
fontWeight: "600", fontWeight: "500",
letterSpacing: 0.5,
}, },
}} }}
> >
@@ -574,9 +592,12 @@ export default function AppLayout() {
), ),
tabBarBadge: (newMatchesCount + unreadCount) > 0 ? (newMatchesCount + unreadCount) : undefined, tabBarBadge: (newMatchesCount + unreadCount) > 0 ? (newMatchesCount + unreadCount) : undefined,
tabBarBadgeStyle: { tabBarBadgeStyle: {
backgroundColor: colors.primary, backgroundColor: colors.premium.rose,
fontSize: 10, fontSize: 9,
fontWeight: '600', fontWeight: '600',
minWidth: 16,
height: 16,
borderRadius: 8,
}, },
}} }}
/> />
@@ -658,6 +679,43 @@ export default function AppLayout() {
outputRange: [0, translateY], 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 ( return (
<Animated.View <Animated.View
key={item.id} key={item.id}
@@ -678,15 +736,20 @@ export default function AppLayout() {
> >
<View style={[ <View style={[
radialStyles.menuItemCircle, radialStyles.menuItemCircle,
item.color ? { backgroundColor: item.color } : null, {
backgroundColor: variantStyles.bg,
borderColor: variantStyles.border,
},
]}> ]}>
<Ionicons <Ionicons
name={item.icon} name={item.icon}
size={22} size={22}
color={colors.text} color={variantStyles.icon}
/> />
</View> </View>
<Text style={radialStyles.menuItemLabel}>{item.label}</Text> <Text style={[radialStyles.menuItemLabel, { color: variantStyles.label }]}>
{item.label}
</Text>
</Pressable> </Pressable>
</Animated.View> </Animated.View>
); );
@@ -890,7 +953,7 @@ const styles = StyleSheet.create({
color: colors.textSecondary, color: colors.textSecondary,
marginTop: 2, marginTop: 2,
}, },
// Play button styles // Play button styles - Premium boutique
playButtonContainer: { playButtonContainer: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
@@ -899,23 +962,38 @@ const styles = StyleSheet.create({
}, },
playButtonGlow: { playButtonGlow: {
position: 'absolute', position: 'absolute',
width: 72, width: 76,
height: 72, height: 76,
borderRadius: 36, borderRadius: 38,
backgroundColor: colors.primaryGlow, backgroundColor: colors.primaryGlow,
opacity: 0.5, opacity: 0.4,
}, },
playButtonGlowActive: { playButtonGlowActive: {
opacity: 1, opacity: 0.7,
...shadows.glow(colors.primary), ...shadows.glow(colors.primary),
}, },
playButtonCircle: { playButtonCircle: {
width: 60, width: 64,
height: 60, height: 64,
borderRadius: 30, borderRadius: 32,
overflow: 'hidden', overflow: 'hidden',
borderWidth: 1.5,
borderColor: 'rgba(233, 69, 96, 0.4)', // primary brand
...shadows.lg, ...shadows.lg,
}, },
playButtonInner: {
flex: 1,
backgroundColor: '#0d0d1a',
alignItems: 'center',
justifyContent: 'center',
},
playButtonHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '50%',
},
playButtonGradient: { playButtonGradient: {
flex: 1, flex: 1,
alignItems: 'center', 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 TAB_BAR_HEIGHT = Platform.OS === 'ios' ? 88 : 64;
const radialStyles = StyleSheet.create({ const radialStyles = StyleSheet.create({
overlay: { overlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.6)', backgroundColor: 'rgba(13, 13, 26, 0.85)',
zIndex: 998, zIndex: 998,
}, },
menuContainer: { menuContainer: {
@@ -948,21 +1026,23 @@ const radialStyles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
menuItemCircle: { menuItemCircle: {
width: 52, width: 56,
height: 52, height: 56,
borderRadius: 26, borderRadius: 28,
backgroundColor: colors.primary, backgroundColor: 'rgba(22, 33, 62, 0.9)',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
...shadows.lg, ...shadows.md,
borderWidth: 2, borderWidth: 1.5,
borderColor: 'rgba(255, 255, 255, 0.2)', borderColor: 'rgba(255, 255, 255, 0.15)',
}, },
menuItemLabel: { menuItemLabel: {
...typography.caption1, ...typography.caption2,
color: colors.text, color: colors.text,
marginTop: 6, marginTop: 8,
fontWeight: '600', fontWeight: '500',
letterSpacing: 1,
textTransform: 'uppercase',
textShadowColor: 'rgba(0, 0, 0, 0.5)', textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 }, textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2, textShadowRadius: 2,
File diff suppressed because it is too large. Load diff
+6 -22
View File
@@ -33,11 +33,6 @@ export default function DaresScreen() {
entering={FadeInDown.duration(600).springify()} entering={FadeInDown.duration(600).springify()}
style={styles.content} style={styles.content}
> >
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name="flame" size={36} color={ACCENT_COLOR} />
</View>
{/* Title section */} {/* Title section */}
<Text style={styles.label}>ADVENTURE</Text> <Text style={styles.label}>ADVENTURE</Text>
<Text style={styles.title}>Dares</Text> <Text style={styles.title}>Dares</Text>
@@ -81,12 +76,10 @@ export default function DaresScreen() {
{/* Bottom teaser */} {/* Bottom teaser */}
<Text style={styles.teaser}>Are you ready to play?</Text> <Text style={styles.teaser}>Are you ready to play?</Text>
{/* Interest button */} {/* Interest button - always reserve space to prevent layout shift */}
<View style={styles.interestButtonContainer}>
{isAuthenticated && !isLoading && ( {isAuthenticated && !isLoading && (
<Animated.View <Animated.View entering={FadeIn.delay(500).duration(400)}>
entering={FadeIn.delay(500).duration(400)}
style={styles.interestButtonContainer}
>
<AnimatedPressable <AnimatedPressable
onPress={toggleInterest} onPress={toggleInterest}
onPressIn={handlePressIn} onPressIn={handlePressIn}
@@ -115,6 +108,7 @@ export default function DaresScreen() {
</AnimatedPressable> </AnimatedPressable>
</Animated.View> </Animated.View>
)} )}
</View>
</Animated.View> </Animated.View>
</View> </View>
</GradientBackground> </GradientBackground>
@@ -127,7 +121,7 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingTop: Platform.OS === 'ios' ? 60 : 40, paddingTop: 60,
paddingBottom: Platform.OS === 'ios' ? 120 : 100, paddingBottom: Platform.OS === 'ios' ? 120 : 100,
}, },
content: { content: {
@@ -135,17 +129,6 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
}, },
iconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(212, 175, 55, 0.1)',
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.xl,
},
label: { label: {
...typography.caption1, ...typography.caption1,
fontWeight: '600', fontWeight: '600',
@@ -223,6 +206,7 @@ const styles = StyleSheet.create({
}, },
interestButtonContainer: { interestButtonContainer: {
marginTop: spacing.xl, marginTop: spacing.xl,
minHeight: 44,
}, },
interestButton: { interestButton: {
flexDirection: 'row', flexDirection: 'row',
+207 -148
View File
@@ -3,6 +3,7 @@ import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, useWind
import Animated, { import Animated, {
FadeInDown, FadeInDown,
FadeInRight, FadeInRight,
FadeIn,
useSharedValue, useSharedValue,
useAnimatedStyle, useAnimatedStyle,
withRepeat, withRepeat,
@@ -17,6 +18,7 @@ import { GradientBackground, GlassCard } from "../../src/components/ui";
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme"; import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
const MAX_CONTENT_WIDTH = 500; const MAX_CONTENT_WIDTH = 500;
const ACCENT_COLOR = colors.primary;
export default function HomeScreen() { export default function HomeScreen() {
const { user, partner, couple } = useAuthStore(); const { user, partner, couple } = useAuthStore();
@@ -35,12 +37,18 @@ export default function HomeScreen() {
// Subtle pulsating animation for the CTA icon // Subtle pulsating animation for the CTA icon
const pulseScale = useSharedValue(1); const pulseScale = useSharedValue(1);
const glowOpacity = useSharedValue(0.3);
useEffect(() => { useEffect(() => {
const startPulse = () => { const startPulse = () => {
pulseScale.value = 1; pulseScale.value = 1;
pulseScale.value = withRepeat( pulseScale.value = withRepeat(
withTiming(1.1, { duration: 1000, easing: Easing.inOut(Easing.ease) }), withTiming(1.1, { duration: 1500, easing: Easing.inOut(Easing.ease) }),
-1,
true
);
glowOpacity.value = withRepeat(
withTiming(0.6, { duration: 1500, easing: Easing.inOut(Easing.ease) }),
-1, -1,
true true
); );
@@ -49,11 +57,13 @@ export default function HomeScreen() {
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, []); }, []);
const pulseAnimatedStyle = useAnimatedStyle(() => { const pulseAnimatedStyle = useAnimatedStyle(() => ({
return {
transform: [{ scale: pulseScale.value }], transform: [{ scale: pulseScale.value }],
}; }));
});
const glowAnimatedStyle = useAnimatedStyle(() => ({
opacity: glowOpacity.value,
}));
return ( return (
<GradientBackground> <GradientBackground>
@@ -66,28 +76,41 @@ export default function HomeScreen() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}> <View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
{/* Header */} {/* Premium Header */}
<Animated.View <Animated.View
entering={FadeInDown.delay(100).duration(500)} entering={FadeInDown.delay(100).duration(600).springify()}
style={styles.header} style={styles.header}
> >
<View style={styles.headerLeft}> {/* Greeting section */}
<Text style={styles.label}>WELCOME BACK</Text>
<Text style={styles.greeting}> <Text style={styles.greeting}>
Hey, {user?.name || "there"} {user?.name || "Beautiful"}
</Text>
{partner ? (
<View style={styles.partnerBadge}>
<Ionicons name="heart" size={12} color={colors.primary} />
<Text style={styles.partnerText}>
{partner.name || partner.email?.split('@')[0] || 'Partner'}
</Text> </Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View> </View>
{/* Partner badge */}
{partner ? (
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.partnerBadge}
>
<Ionicons name="heart" size={12} color={ACCENT_COLOR} />
<Text style={styles.partnerText}>
Connected with {partner.name || partner.email?.split('@')[0] || 'Partner'}
</Text>
</Animated.View>
) : couple ? ( ) : couple ? (
<TouchableOpacity <TouchableOpacity
style={styles.waitingBadge} style={styles.waitingBadge}
onPress={() => router.push("/(app)/pairing")} onPress={() => router.push("/(app)/pairing")}
> >
<Ionicons name="hourglass-outline" size={12} color={colors.primary} /> <Ionicons name="hourglass-outline" size={12} color={ACCENT_COLOR} />
<Text style={styles.waitingText}>Waiting for partner</Text> <Text style={styles.waitingText}>Waiting for partner</Text>
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
@@ -96,27 +119,10 @@ export default function HomeScreen() {
onPress={() => router.push("/(app)/pairing")} onPress={() => router.push("/(app)/pairing")}
> >
<Ionicons name="add-circle-outline" size={12} color={colors.textTertiary} /> <Ionicons name="add-circle-outline" size={12} color={colors.textTertiary} />
<Text style={styles.pairText}>Pair with partner</Text> <Text style={styles.pairText}>Connect with your partner</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View>
<TouchableOpacity
onPress={() => router.push("/(app)/profile")}
activeOpacity={0.8}
>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.avatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.avatarInner}>
<Text style={styles.avatarText}>
{user?.name?.[0]?.toUpperCase() || "U"}
</Text>
</View>
</LinearGradient>
</TouchableOpacity>
</Animated.View> </Animated.View>
{/* Stats Row */} {/* Stats Row */}
@@ -129,24 +135,17 @@ export default function HomeScreen() {
onPress={() => router.push("/(app)/matches")} onPress={() => router.push("/(app)/matches")}
activeOpacity={0.7} activeOpacity={0.7}
> >
<GlassCard> <View style={[styles.statCard, matches.length > 0 && styles.statCardActive]}>
<View style={styles.statContent}> <View style={[styles.statIcon, matches.length > 0 && styles.statIconActive]}>
<LinearGradient
colors={matches.length > 0 ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
style={styles.statIcon}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons <Ionicons
name="heart" name="heart"
size={18} size={18}
color={matches.length > 0 ? colors.text : colors.primary} color={matches.length > 0 ? colors.text : ACCENT_COLOR}
/> />
</LinearGradient> </View>
<Text style={styles.statNumber}>{matches.length}</Text> <Text style={styles.statNumber}>{matches.length}</Text>
<Text style={styles.statLabel}>Matches</Text> <Text style={styles.statLabel}>Matches</Text>
</View> </View>
</GlassCard>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -154,24 +153,17 @@ export default function HomeScreen() {
onPress={() => router.push("/(app)/packs")} onPress={() => router.push("/(app)/packs")}
activeOpacity={0.7} activeOpacity={0.7}
> >
<GlassCard> <View style={[styles.statCard, enabledPacksCount > 0 && styles.statCardActive]}>
<View style={styles.statContent}> <View style={[styles.statIcon, enabledPacksCount > 0 && styles.statIconActive]}>
<LinearGradient
colors={enabledPacksCount > 0 ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
style={styles.statIcon}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons <Ionicons
name="layers" name="layers"
size={18} size={18}
color={enabledPacksCount > 0 ? colors.text : colors.primary} color={enabledPacksCount > 0 ? colors.text : ACCENT_COLOR}
/> />
</LinearGradient> </View>
<Text style={styles.statNumber}>{enabledPacksCount}</Text> <Text style={styles.statNumber}>{enabledPacksCount}</Text>
<Text style={styles.statLabel}>Packs</Text> <Text style={styles.statLabel}>Packs</Text>
</View> </View>
</GlassCard>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -179,24 +171,17 @@ export default function HomeScreen() {
onPress={() => router.push("/(app)/matches")} onPress={() => router.push("/(app)/matches")}
activeOpacity={0.7} activeOpacity={0.7}
> >
<GlassCard> <View style={[styles.statCard, newMatchesCount > 0 && styles.statCardActive]}>
<View style={styles.statContent}> <View style={[styles.statIcon, newMatchesCount > 0 && styles.statIconActive]}>
<LinearGradient
colors={newMatchesCount > 0 ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
style={styles.statIcon}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons <Ionicons
name="sparkles" name="sparkles"
size={18} size={18}
color={newMatchesCount > 0 ? colors.text : colors.primary} color={newMatchesCount > 0 ? colors.text : ACCENT_COLOR}
/> />
</LinearGradient> </View>
<Text style={styles.statNumber}>{newMatchesCount}</Text> <Text style={styles.statNumber}>{newMatchesCount}</Text>
<Text style={styles.statLabel}>New</Text> <Text style={styles.statLabel}>New</Text>
</View> </View>
</GlassCard>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
@@ -209,22 +194,28 @@ export default function HomeScreen() {
onPress={() => router.push("/(app)/swipe")} onPress={() => router.push("/(app)/swipe")}
activeOpacity={0.9} activeOpacity={0.9}
> >
<View style={styles.ctaCard}>
{/* Glow effect */}
<Animated.View style={[styles.ctaGlow, glowAnimatedStyle]} />
<LinearGradient <LinearGradient
colors={gradients.primary as [string, string]} colors={gradients.primary as [string, string]}
style={styles.ctaCard} style={styles.ctaGradient}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
> >
<View style={styles.ctaContent}> <View style={styles.ctaContent}>
<Text style={styles.ctaLabel}>EXPLORE</Text>
<Text style={styles.ctaTitle}>Ready to explore?</Text> <Text style={styles.ctaTitle}>Ready to explore?</Text>
<Text style={styles.ctaSubtitle}> <Text style={styles.ctaSubtitle}>
Swipe through questions and discover what you both enjoy Discover new experiences
</Text> </Text>
</View> </View>
<Animated.View style={[styles.ctaIconContainer, pulseAnimatedStyle]}> <Animated.View style={[styles.ctaIconContainer, pulseAnimatedStyle]}>
<Ionicons name="flame" size={24} color={colors.text} /> <Ionicons name="flame" size={28} color={colors.text} />
</Animated.View> </Animated.View>
</LinearGradient> </LinearGradient>
</View>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
@@ -235,13 +226,18 @@ export default function HomeScreen() {
style={styles.section} style={styles.section}
> >
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<View style={styles.sectionTitleRow}>
<View style={styles.sectionIcon}>
<Ionicons name="heart" size={14} color={ACCENT_COLOR} />
</View>
<Text style={styles.sectionTitle}>Recent Matches</Text> <Text style={styles.sectionTitle}>Recent Matches</Text>
</View>
<TouchableOpacity <TouchableOpacity
onPress={() => router.push("/(app)/matches")} onPress={() => router.push("/(app)/matches")}
style={styles.seeAllButton} style={styles.seeAllButton}
> >
<Text style={styles.seeAllText}>See all</Text> <Text style={styles.seeAllText}>See all</Text>
<Ionicons name="chevron-forward" size={14} color={colors.primary} /> <Ionicons name="chevron-forward" size={14} color={ACCENT_COLOR} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{recentMatches.map((match, index) => ( {recentMatches.map((match, index) => (
@@ -253,7 +249,7 @@ export default function HomeScreen() {
onPress={() => router.push(`/chat/${match.id}`)} onPress={() => router.push(`/chat/${match.id}`)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<GlassCard style={styles.matchCard}> <View style={styles.matchCard}>
<View style={styles.matchRow}> <View style={styles.matchRow}>
<LinearGradient <LinearGradient
colors={gradients.primary as [string, string]} colors={gradients.primary as [string, string]}
@@ -261,14 +257,14 @@ export default function HomeScreen() {
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
> >
<Ionicons name="heart" size={16} color={colors.text} /> <Ionicons name="heart" size={14} color={colors.text} />
</LinearGradient> </LinearGradient>
<Text style={styles.matchText} numberOfLines={1}> <Text style={styles.matchText} numberOfLines={1}>
{(match as any).question?.text || "A new match!"} {(match as any).question?.text || "A new match!"}
</Text> </Text>
{match.is_new && <View style={styles.newBadge} />} {match.is_new && <View style={styles.newBadge} />}
</View> </View>
</GlassCard> </View>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
))} ))}
@@ -281,40 +277,48 @@ export default function HomeScreen() {
entering={FadeInDown.delay(400).duration(500)} entering={FadeInDown.delay(400).duration(500)}
style={styles.section} style={styles.section}
> >
<Text style={styles.tipsTitle}>Getting Started</Text> <View style={styles.sectionHeader}>
<GlassCard> <View style={styles.sectionTitleRow}>
<View style={styles.sectionIcon}>
<Ionicons name="compass" size={14} color={ACCENT_COLOR} />
</View>
<Text style={styles.sectionTitle}>Getting Started</Text>
</View>
</View>
<View style={styles.tipCard}>
<View style={styles.tipRow}> <View style={styles.tipRow}>
<View style={styles.tipIcon}> <View style={styles.tipIcon}>
<Ionicons name="layers-outline" size={18} color={colors.primary} /> <Ionicons name="layers-outline" size={18} color={ACCENT_COLOR} />
</View> </View>
<View style={styles.tipContent}> <View style={styles.tipContent}>
<Text style={styles.tipText}>Enable question packs</Text> <Text style={styles.tipText}>Enable question packs</Text>
<Text style={styles.tipSubtext}>Choose topics you want to explore</Text> <Text style={styles.tipSubtext}>Choose topics to explore together</Text>
</View> </View>
</View> </View>
</GlassCard> </View>
<GlassCard style={styles.tipCard}> <View style={[styles.tipCard, styles.tipCardSpaced]}>
<View style={styles.tipRow}> <View style={styles.tipRow}>
<View style={styles.tipIcon}> <View style={styles.tipIcon}>
<Ionicons name="swap-horizontal-outline" size={18} color={colors.primary} /> <Ionicons name="swap-horizontal-outline" size={18} color={ACCENT_COLOR} />
</View> </View>
<View style={styles.tipContent}> <View style={styles.tipContent}>
<Text style={styles.tipText}>Swipe on questions</Text> <Text style={styles.tipText}>Swipe on questions</Text>
<Text style={styles.tipSubtext}>Answer yes, no, or maybe</Text> <Text style={styles.tipSubtext}>Answer yes, no, or maybe</Text>
</View> </View>
</View> </View>
</GlassCard> </View>
<GlassCard style={styles.tipCard}> <View style={[styles.tipCard, styles.tipCardSpaced]}>
<View style={styles.tipRow}> <View style={styles.tipRow}>
<View style={styles.tipIcon}> <View style={styles.tipIcon}>
<Ionicons name="heart-outline" size={18} color={colors.primary} /> <Ionicons name="heart-outline" size={18} color={ACCENT_COLOR} />
</View> </View>
<View style={styles.tipContent}> <View style={styles.tipContent}>
<Text style={styles.tipText}>Discover matches</Text> <Text style={styles.tipText}>Discover matches</Text>
<Text style={styles.tipSubtext}>See where you both agree</Text> <Text style={styles.tipSubtext}>See where you both agree</Text>
</View> </View>
</View> </View>
</GlassCard> </View>
</Animated.View> </Animated.View>
)} )}
@@ -346,27 +350,51 @@ const styles = StyleSheet.create({
header: { header: {
paddingTop: 60, paddingTop: 60,
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg, paddingBottom: spacing.xl,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
}, },
headerLeft: { label: {
flex: 1, ...typography.caption2,
fontWeight: '600',
letterSpacing: 2.5,
color: ACCENT_COLOR,
textAlign: 'center',
marginBottom: spacing.xs,
}, },
greeting: { greeting: {
...typography.title1, ...typography.largeTitle,
color: colors.text, color: colors.text,
textAlign: 'center',
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.md,
width: 120,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(233, 69, 96, 0.3)',
},
separatorDiamond: {
width: 5,
height: 5,
backgroundColor: ACCENT_COLOR,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.sm,
opacity: 0.6,
}, },
partnerBadge: { partnerBadge: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.primaryLight, backgroundColor: 'rgba(233, 69, 96, 0.1)',
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.md,
paddingVertical: spacing.xs, paddingVertical: spacing.sm,
borderRadius: radius.full, borderRadius: radius.full,
alignSelf: 'flex-start', borderWidth: 1,
marginTop: spacing.sm, borderColor: 'rgba(233, 69, 96, 0.2)',
gap: spacing.xs, gap: spacing.xs,
}, },
partnerText: { partnerText: {
@@ -378,11 +406,11 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.glass.background, backgroundColor: colors.glass.background,
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.md,
paddingVertical: spacing.xs, paddingVertical: spacing.sm,
borderRadius: radius.full, borderRadius: radius.full,
alignSelf: 'flex-start', borderWidth: 1,
marginTop: spacing.sm, borderColor: colors.glass.border,
gap: spacing.xs, gap: spacing.xs,
}, },
pairText: { pairText: {
@@ -392,12 +420,12 @@ const styles = StyleSheet.create({
waitingBadge: { waitingBadge: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.primaryLight, backgroundColor: 'rgba(233, 69, 96, 0.1)',
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.md,
paddingVertical: spacing.xs, paddingVertical: spacing.sm,
borderRadius: radius.full, borderRadius: radius.full,
alignSelf: 'flex-start', borderWidth: 1,
marginTop: spacing.sm, borderColor: 'rgba(233, 69, 96, 0.2)',
gap: spacing.xs, gap: spacing.xs,
}, },
waitingText: { waitingText: {
@@ -405,26 +433,6 @@ const styles = StyleSheet.create({
color: colors.text, color: colors.text,
fontWeight: '500', fontWeight: '500',
}, },
avatarGradient: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
...shadows.md,
},
avatarInner: {
width: 42,
height: 42,
borderRadius: 21,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
...typography.headline,
color: colors.text,
},
statsContainer: { statsContainer: {
flexDirection: "row", flexDirection: "row",
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
@@ -433,18 +441,30 @@ const styles = StyleSheet.create({
statCardWrapper: { statCardWrapper: {
flex: 1, flex: 1,
}, },
statContent: { statCard: {
alignItems: "center", alignItems: "center",
paddingVertical: spacing.sm, paddingVertical: spacing.md,
backgroundColor: 'rgba(233, 69, 96, 0.05)',
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.1)',
},
statCardActive: {
backgroundColor: 'rgba(233, 69, 96, 0.1)',
borderColor: 'rgba(233, 69, 96, 0.2)',
}, },
statIcon: { statIcon: {
width: 36, width: 36,
height: 36, height: 36,
borderRadius: 18, borderRadius: 18,
backgroundColor: 'rgba(233, 69, 96, 0.1)',
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
marginBottom: spacing.sm, marginBottom: spacing.sm,
}, },
statIconActive: {
backgroundColor: ACCENT_COLOR,
},
statNumber: { statNumber: {
...typography.title2, ...typography.title2,
color: colors.text, color: colors.text,
@@ -456,9 +476,23 @@ const styles = StyleSheet.create({
}, },
ctaSection: { ctaSection: {
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
marginTop: spacing.lg, marginTop: spacing.xl,
}, },
ctaCard: { ctaCard: {
position: 'relative',
borderRadius: radius.xl,
overflow: 'hidden',
},
ctaGlow: {
position: 'absolute',
top: -20,
left: -20,
right: -20,
bottom: -20,
backgroundColor: ACCENT_COLOR,
borderRadius: radius.xl + 20,
},
ctaGradient: {
borderRadius: radius.xl, borderRadius: radius.xl,
padding: spacing.lg, padding: spacing.lg,
flexDirection: "row", flexDirection: "row",
@@ -468,21 +502,29 @@ const styles = StyleSheet.create({
ctaContent: { ctaContent: {
flex: 1, flex: 1,
}, },
ctaLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: 'rgba(255,255,255,0.8)',
marginBottom: spacing.xs,
},
ctaTitle: { ctaTitle: {
...typography.title3, ...typography.title2,
color: colors.text, color: colors.text,
marginBottom: spacing.xs, marginBottom: spacing.xs,
}, },
ctaSubtitle: { ctaSubtitle: {
...typography.subhead, ...typography.subhead,
color: "rgba(255,255,255,0.8)", color: "rgba(255,255,255,0.8)",
lineHeight: 20,
}, },
ctaIconContainer: { ctaIconContainer: {
width: 48, width: 56,
height: 48, height: 56,
borderRadius: 24, borderRadius: 28,
backgroundColor: "rgba(255,255,255,0.2)", backgroundColor: "rgba(255,255,255,0.2)",
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)',
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
marginLeft: spacing.md, marginLeft: spacing.md,
@@ -497,6 +539,19 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
marginBottom: spacing.md, marginBottom: spacing.md,
}, },
sectionTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
sectionIcon: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: 'rgba(233, 69, 96, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
sectionTitle: { sectionTitle: {
...typography.headline, ...typography.headline,
color: colors.text, color: colors.text,
@@ -508,19 +563,24 @@ const styles = StyleSheet.create({
}, },
seeAllText: { seeAllText: {
...typography.subhead, ...typography.subhead,
color: colors.primary, color: ACCENT_COLOR,
}, },
matchCard: { matchCard: {
marginBottom: spacing.sm, marginBottom: spacing.sm,
backgroundColor: 'rgba(233, 69, 96, 0.05)',
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.1)',
padding: spacing.md,
}, },
matchRow: { matchRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
}, },
matchIcon: { matchIcon: {
width: 32, width: 28,
height: 32, height: 28,
borderRadius: 16, borderRadius: 14,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
marginRight: spacing.md, marginRight: spacing.md,
@@ -534,17 +594,16 @@ const styles = StyleSheet.create({
width: 8, width: 8,
height: 8, height: 8,
borderRadius: 4, borderRadius: 4,
backgroundColor: colors.primary, backgroundColor: ACCENT_COLOR,
},
tipsTitle: {
...typography.caption1,
color: colors.textTertiary,
textTransform: 'uppercase',
letterSpacing: 1.5,
marginBottom: spacing.sm,
marginLeft: spacing.xs,
}, },
tipCard: { tipCard: {
backgroundColor: 'rgba(233, 69, 96, 0.05)',
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.1)',
padding: spacing.md,
},
tipCardSpaced: {
marginTop: spacing.sm, marginTop: spacing.sm,
}, },
tipRow: { tipRow: {
@@ -555,7 +614,7 @@ const styles = StyleSheet.create({
width: 36, width: 36,
height: 36, height: 36,
borderRadius: 18, borderRadius: 18,
backgroundColor: colors.primaryLight, backgroundColor: 'rgba(233, 69, 96, 0.1)',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginRight: spacing.md, marginRight: spacing.md,
+414 -142
View File
@@ -5,12 +5,30 @@ import { useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated"; import Animated, {
import { GradientBackground, GlassCard } from "../../src/components/ui"; FadeIn,
FadeInDown,
FadeInRight,
FadeInUp,
useSharedValue,
useAnimatedStyle,
withRepeat,
withSequence,
withTiming,
interpolate,
Easing,
} from "react-native-reanimated";
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme"; import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
import MatchesTutorial from "../../src/components/MatchesTutorial"; import MatchesTutorial from "../../src/components/MatchesTutorial";
import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen"; import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen";
// Premium color palette
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
const ROSE = colors.premium.rose;
const ROSE_RGBA = 'rgba(232, 164, 174, ';
const MAX_CONTENT_WIDTH = 500; const MAX_CONTENT_WIDTH = 500;
export default function MatchesScreen() { export default function MatchesScreen() {
@@ -21,6 +39,60 @@ export default function MatchesScreen() {
const isWideScreen = width > MAX_CONTENT_WIDTH; const isWideScreen = width > MAX_CONTENT_WIDTH;
const [showTutorial, setShowTutorial] = useState(false); const [showTutorial, setShowTutorial] = useState(false);
// Ambient orb breathing animations
const orbBreathing1 = useSharedValue(0);
const orbBreathing2 = useSharedValue(0);
const orbDrift = useSharedValue(0);
useEffect(() => {
// Primary orb breathing - 6 second cycle
orbBreathing1.value = withRepeat(
withSequence(
withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
// Secondary orb breathing - offset timing for variation
orbBreathing2.value = withRepeat(
withSequence(
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
// Subtle vertical drift - 8 second cycle
orbDrift.value = withRepeat(
withSequence(
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 4000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const orbStyle1 = useAnimatedStyle(() => ({
opacity: interpolate(orbBreathing1.value, [0, 1], [0.25, 0.5]),
transform: [
{ translateY: interpolate(orbDrift.value, [0, 1], [0, -20]) },
{ scale: interpolate(orbBreathing1.value, [0, 1], [1, 1.1]) },
],
}));
const orbStyle2 = useAnimatedStyle(() => ({
opacity: interpolate(orbBreathing2.value, [0, 1], [0.2, 0.4]),
transform: [
{ translateY: interpolate(orbDrift.value, [0, 1], [20, 0]) },
{ scale: interpolate(orbBreathing2.value, [0, 1], [1, 1.1]) },
],
}));
// Check if tutorial should be shown when screen is focused or matches change // Check if tutorial should be shown when screen is focused or matches change
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
@@ -78,52 +150,77 @@ export default function MatchesScreen() {
onPress={() => router.push(`/chat/${item.id}`)} onPress={() => router.push(`/chat/${item.id}`)}
activeOpacity={0.8} activeOpacity={0.8}
> >
<GlassCard style={styles.matchCard}> <View style={styles.matchCardPremium}>
<View style={styles.matchRow}> {/* Subtle gradient background */}
<LinearGradient <LinearGradient
colors={isYesYes ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]} colors={['rgba(22, 33, 62, 0.6)', 'rgba(13, 13, 26, 0.8)']}
style={styles.iconContainer} style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
> />
{/* Top silk highlight */}
<LinearGradient
colors={[`${ACCENT_RGBA}0.06)`, 'transparent']}
style={styles.cardSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={styles.matchRow}>
{/* Premium icon container */}
<View style={[
styles.iconContainerPremium,
isYesYes && styles.iconContainerYesYes
]}>
<Ionicons <Ionicons
name={isYesYes ? "heart" : "heart-half"} name={isYesYes ? "heart" : "heart-half"}
size={22} size={20}
color={isYesYes ? colors.text : colors.primary} color={isYesYes ? ACCENT : ROSE}
/> />
</LinearGradient> </View>
<View style={styles.content}> <View style={styles.content}>
<Text style={styles.questionText} numberOfLines={2}> <Text style={styles.questionTextPremium} numberOfLines={2}>
{userText} {userText}
</Text> </Text>
{item.question.partner_text && ( {item.question.partner_text && (
<Text style={styles.partnerText} numberOfLines={1}> <Text style={styles.partnerTextPremium} numberOfLines={1}>
Partner: {partnerText} Partner: {partnerText}
</Text> </Text>
)} )}
<View style={styles.metaRow}> <View style={styles.metaRow}>
<View style={[styles.tag, isYesYes && styles.tagHighlight]}> <View style={[
<Text style={[styles.tagText, isYesYes && styles.tagTextHighlight]}> styles.tagPremium,
isYesYes && styles.tagPremiumHighlight
]}>
<Text style={[
styles.tagTextPremium,
isYesYes && styles.tagTextPremiumHighlight
]}>
{isYesYes ? "YES + YES" : "YES + MAYBE"} {isYesYes ? "YES + YES" : "YES + MAYBE"}
</Text> </Text>
</View> </View>
<Text style={styles.date}> <Text style={styles.datePremium}>
{new Date(item.created_at).toLocaleDateString()} {new Date(item.created_at).toLocaleDateString()}
</Text> </Text>
</View> </View>
</View> </View>
<View style={styles.rightSection}> <View style={styles.rightSection}>
{item.unreadCount > 0 && ( {item.unreadCount > 0 && (
<View style={styles.unreadBadge}> <View style={styles.unreadBadgePremium}>
<Ionicons name="chatbubble" size={12} color={colors.text} /> <Ionicons name="chatbubble" size={10} color={colors.text} />
</View> </View>
)} )}
<View style={styles.chevronContainer}> <View style={styles.chevronContainerPremium}>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} /> <Ionicons name="chevron-forward" size={16} color={`${ACCENT_RGBA}0.6)`} />
</View> </View>
</View> </View>
</View> </View>
</GlassCard>
{/* Premium border */}
<View style={styles.cardPremiumBorder} pointerEvents="none" />
</View>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
); );
@@ -141,21 +238,48 @@ export default function MatchesScreen() {
return ( return (
<GradientBackground> <GradientBackground>
{/* Ambient Orbs - Premium gold/rose */}
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
<LinearGradient
colors={[colors.premium.goldGlow, 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
<Animated.View style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]} pointerEvents="none">
<LinearGradient
colors={[`${ROSE_RGBA}0.2)`, 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 0, y: 0 }}
/>
</Animated.View>
<View style={styles.container}> <View style={styles.container}>
{/* Premium Header */}
<Animated.View <Animated.View
entering={FadeInDown.duration(400)} entering={FadeIn.duration(400)}
style={[styles.header, isWideScreen && styles.headerWide]} style={[styles.header, isWideScreen && styles.headerWide]}
> >
<View style={styles.headerTop}> <View style={styles.headerContent}>
<Text style={styles.title}>Matches</Text> {/* Premium label */}
<View style={styles.countBadge}> <Text style={styles.headerLabel}>DISCOVER</Text>
<Ionicons name="heart" size={14} color={colors.primary} /> <Text style={styles.headerTitle}>Matches</Text>
<Text style={styles.countText}>{matches.length}</Text>
{/* Decorative separator */}
<View style={styles.headerSeparator}>
<View style={styles.headerSeparatorLine} />
<View style={styles.headerSeparatorDiamond} />
<View style={styles.headerSeparatorLine} />
</View>
{/* Count badge */}
<View style={styles.countBadgePremium}>
<Ionicons name="heart" size={12} color={ACCENT} />
<Text style={styles.countTextPremium}>{matches.length} {matches.length === 1 ? 'MATCH' : 'MATCHES'}</Text>
</View> </View>
</View> </View>
<Text style={styles.subtitle}>
Discover what you both enjoy together
</Text>
</Animated.View> </Animated.View>
<FlatList <FlatList
@@ -174,38 +298,63 @@ export default function MatchesScreen() {
} }
ListEmptyComponent={ ListEmptyComponent={
<Animated.View <Animated.View
entering={FadeInDown.delay(200).duration(400)} entering={FadeInUp.duration(600).springify()}
style={styles.emptyContainer} style={styles.emptyContent}
> >
<LinearGradient {/* Premium icon */}
colors={gradients.primary as [string, string]} <View style={styles.emptyIconContainerPremium}>
style={styles.emptyIconGradient} <Ionicons name="heart-outline" size={36} color={ROSE} />
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.emptyIconInner}>
<Ionicons name="heart-outline" size={40} color={colors.primary} />
</View> </View>
</LinearGradient>
<Text style={styles.emptyTitle}>No matches yet</Text> {/* Title section */}
<Text style={styles.emptyText}> <Text style={styles.emptyLabel}>BEGIN</Text>
Keep swiping to find things you both enjoy! <Text style={styles.emptyTitlePremium}>No Matches Yet</Text>
{/* Decorative separator */}
<View style={styles.emptySeparator}>
<View style={styles.emptySeparatorLine} />
<View style={styles.emptySeparatorDiamond} />
<View style={styles.emptySeparatorLine} />
</View>
{/* Status badge */}
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.emptyBadge}
>
<Text style={styles.emptyBadgeText}>START EXPLORING</Text>
</Animated.View>
{/* Description */}
<Text style={styles.emptyDescription}>
Answer questions together to discover what you both enjoy. Matches appear when you agree!
</Text> </Text>
<TouchableOpacity
style={styles.emptyButton} {/* Feature hints */}
<View style={styles.emptyFeatures}>
<View style={styles.emptyFeatureItem}>
<Ionicons name="heart" size={16} color={ROSE} />
<Text style={styles.emptyFeatureText}>Swipe right for yes</Text>
</View>
<View style={styles.emptyFeatureItem}>
<Ionicons name="sparkles" size={16} color={ROSE} />
<Text style={styles.emptyFeatureText}>Match when you both agree</Text>
</View>
<View style={styles.emptyFeatureItem}>
<Ionicons name="chatbubbles-outline" size={16} color={ROSE} />
<Text style={styles.emptyFeatureText}>Chat about your matches</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.emptyTeaser}>Your first match is just a swipe away</Text>
<GlassButton
onPress={() => router.push("/(app)/swipe")} onPress={() => router.push("/(app)/swipe")}
activeOpacity={0.8} style={{ marginTop: spacing.lg }}
> >
<LinearGradient Start Swiping
colors={gradients.primary as [string, string]} </GlassButton>
style={styles.emptyButtonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="flame" size={18} color={colors.text} />
<Text style={styles.emptyButtonText}>Start Swiping</Text>
</LinearGradient>
</TouchableOpacity>
</Animated.View> </Animated.View>
} }
/> />
@@ -228,47 +377,93 @@ const styles = StyleSheet.create({
justifyContent: "center", justifyContent: "center",
alignItems: "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: { header: {
paddingTop: 60, paddingTop: 60,
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg, paddingBottom: spacing.md,
}, },
headerWide: { headerWide: {
alignSelf: 'center', alignSelf: 'center',
width: '100%', width: '100%',
maxWidth: MAX_CONTENT_WIDTH, maxWidth: MAX_CONTENT_WIDTH,
}, },
headerTop: { headerContent: {
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between',
}, },
title: { headerLabel: {
...typography.title1, ...typography.caption2,
fontWeight: '600',
letterSpacing: 3,
color: ACCENT,
marginBottom: spacing.xs,
},
headerTitle: {
...typography.largeTitle,
color: colors.text, color: colors.text,
textAlign: 'center',
}, },
countBadge: { headerSeparator: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.primaryLight, justifyContent: 'center',
paddingHorizontal: spacing.sm, marginVertical: spacing.md,
paddingVertical: spacing.xs, 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, borderRadius: radius.full,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
gap: spacing.xs, gap: spacing.xs,
}, },
countText: { countTextPremium: {
...typography.subhead, ...typography.caption2,
color: colors.text,
fontWeight: '600', fontWeight: '600',
letterSpacing: 2,
color: ACCENT,
}, },
subtitle: { // List
...typography.subhead,
color: colors.textSecondary,
marginTop: spacing.xs,
},
list: { list: {
padding: spacing.lg, padding: spacing.lg,
paddingTop: 0, paddingTop: spacing.sm,
paddingBottom: Platform.OS === 'ios' ? 120 : 100, paddingBottom: Platform.OS === 'ios' ? 120 : 100,
}, },
listWide: { listWide: {
@@ -276,32 +471,60 @@ const styles = StyleSheet.create({
width: '100%', width: '100%',
maxWidth: MAX_CONTENT_WIDTH, maxWidth: MAX_CONTENT_WIDTH,
}, },
matchCard: { // Premium Match Card
matchCardPremium: {
marginBottom: spacing.sm, 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: { matchRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
}, },
iconContainer: { iconContainerPremium: {
width: 48, width: 44,
height: 48, height: 44,
borderRadius: 24, borderRadius: 22,
backgroundColor: `${ROSE_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ROSE_RGBA}0.2)`,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
marginRight: spacing.md, marginRight: spacing.md,
}, },
iconContainerYesYes: {
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderColor: `${ACCENT_RGBA}0.2)`,
},
content: { content: {
flex: 1, flex: 1,
}, },
questionText: { questionTextPremium: {
...typography.body, ...typography.subhead,
color: colors.text, color: colors.text,
fontWeight: "600", fontWeight: "600",
marginBottom: spacing.xs, marginBottom: spacing.xs,
lineHeight: 22, lineHeight: 20,
}, },
partnerText: { partnerTextPremium: {
...typography.caption1, ...typography.caption1,
color: colors.textSecondary, color: colors.textSecondary,
fontStyle: "italic", fontStyle: "italic",
@@ -312,27 +535,31 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
}, },
tag: { tagPremium: {
backgroundColor: colors.glass.background, backgroundColor: `${ROSE_RGBA}0.1)`,
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
paddingVertical: 4, paddingVertical: 3,
borderRadius: radius.sm, borderRadius: radius.sm,
borderWidth: 1,
borderColor: `${ROSE_RGBA}0.15)`,
}, },
tagHighlight: { tagPremiumHighlight: {
backgroundColor: colors.primaryLight, backgroundColor: `${ACCENT_RGBA}0.1)`,
borderColor: `${ACCENT_RGBA}0.2)`,
}, },
tagText: { tagTextPremium: {
...typography.caption2, ...typography.caption2,
color: colors.textSecondary, color: ROSE,
fontWeight: "700", fontWeight: "600",
letterSpacing: 0.5, letterSpacing: 1,
}, },
tagTextHighlight: { tagTextPremiumHighlight: {
color: colors.primary, color: ACCENT,
}, },
date: { datePremium: {
...typography.caption2, ...typography.caption2,
color: colors.textTertiary, color: colors.textTertiary,
fontStyle: 'italic',
}, },
rightSection: { rightSection: {
flexDirection: 'row', flexDirection: 'row',
@@ -340,70 +567,115 @@ const styles = StyleSheet.create({
gap: spacing.sm, gap: spacing.sm,
marginLeft: spacing.sm, marginLeft: spacing.sm,
}, },
unreadBadge: { unreadBadgePremium: {
width: 24, width: 22,
height: 24, height: 22,
borderRadius: 12, borderRadius: 11,
backgroundColor: colors.primary, backgroundColor: ACCENT,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
chevronContainer: { chevronContainerPremium: {
width: 28, width: 28,
height: 28, height: 28,
borderRadius: 14, borderRadius: 14,
backgroundColor: colors.glass.background, backgroundColor: `${ACCENT_RGBA}0.08)`,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.15)`,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
emptyContainer: { // Premium Empty State
alignItems: "center", emptyContent: {
paddingVertical: spacing.xxl, width: '100%',
paddingHorizontal: spacing.lg, alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xl,
}, },
emptyIconGradient: { emptyIconContainerPremium: {
width: 100, width: 72,
height: 100, height: 72,
borderRadius: 50, borderRadius: 36,
backgroundColor: `${ROSE_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ROSE_RGBA}0.2)`,
justifyContent: 'center', justifyContent: 'center',
alignItems: '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, marginBottom: spacing.xl,
}, },
emptyButton: { emptyLabel: {
borderRadius: radius.full, ...typography.caption1,
overflow: 'hidden', fontWeight: '600',
...shadows.md, letterSpacing: 3,
color: ROSE,
textAlign: 'center',
marginBottom: spacing.xs,
}, },
emptyButtonGradient: { emptyTitlePremium: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
emptySeparator: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: spacing.lg, justifyContent: 'center',
paddingVertical: spacing.md, marginVertical: spacing.lg,
gap: spacing.sm, width: 140,
}, },
emptyButtonText: { emptySeparatorLine: {
...typography.body, flex: 1,
color: colors.text, 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', 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',
}, },
}); });
+1 -1
View File
@@ -605,7 +605,7 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingTop: Platform.OS === 'ios' ? 60 : 40, paddingTop: 60,
paddingBottom: spacing.md, paddingBottom: spacing.md,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
}, },
-300
View File
@@ -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<FilterType>('all');
const [showPaywall, setShowPaywall] = useState(false);
const [showTeaser, setShowTeaser] = useState(false);
const [teaserPack, setTeaserPack] = useState<QuestionPack | null>(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 (
<GradientBackground>
<View style={styles.container}>
{/* Header */}
<Animated.View
entering={FadeInDown.duration(400)}
style={styles.header}
>
<Text style={styles.title}>Question Packs</Text>
<Text style={styles.subtitle}>
{totalQuestions} {totalQuestions === 1 ? 'question' : 'questions'} from {enabledPackCount} {enabledPackCount === 1 ? 'pack' : 'packs'} enabled
</Text>
</Animated.View>
{/* Category Tabs */}
<Animated.View entering={FadeInDown.delay(100).duration(400)}>
<CategoryTabs
categories={categories}
selectedFilter={selectedFilter}
onSelectFilter={setSelectedFilter}
enabledCount={enabledPackIds.length}
/>
</Animated.View>
{/* Masonry Grid */}
<ScrollView
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isLoading}
onRefresh={fetchPacks}
tintColor={colors.primary}
colors={[colors.primary]}
/>
}
contentContainerStyle={styles.scrollContent}
>
{filteredPacks.length === 0 ? (
<Animated.View
entering={FadeInDown.delay(200).duration(400)}
style={styles.emptyContainer}
>
<View style={styles.emptyIconContainer}>
<Ionicons name="layers-outline" size={48} color={colors.textTertiary} />
</View>
<Text style={styles.emptyTitle}>No packs available</Text>
<Text style={styles.emptyText}>
{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!"}
</Text>
</Animated.View>
) : (
<View style={styles.masonryContainer}>
{/* Left Column */}
<View style={styles.column}>
{leftColumn.map(({ pack, index }, columnIndex) => (
<Animated.View
key={pack.id}
entering={FadeInDown.delay(columnIndex * 80).duration(400)}
layout={Layout.springify()}
>
<BoutiquePackCard
pack={pack}
index={index}
isEnabled={enabledPackIds.includes(pack.id)}
isPremiumLocked={pack.is_premium && !hasPremiumAccess}
onPress={() => handlePackPress(pack)}
onToggle={() => handleTogglePack(pack)}
/>
</Animated.View>
))}
</View>
{/* Right Column */}
<View style={styles.column}>
{rightColumn.map(({ pack, index }, columnIndex) => (
<Animated.View
key={pack.id}
entering={FadeInDown.delay(columnIndex * 80 + 40).duration(400)}
layout={Layout.springify()}
>
<BoutiquePackCard
pack={pack}
index={index}
isEnabled={enabledPackIds.includes(pack.id)}
isPremiumLocked={pack.is_premium && !hasPremiumAccess}
onPress={() => handlePackPress(pack)}
onToggle={() => handleTogglePack(pack)}
/>
</Animated.View>
))}
</View>
</View>
)}
</ScrollView>
{/* Modals */}
{teaserPack && (
<PackTeaser
visible={showTeaser}
packId={teaserPack.id}
packName={teaserPack.name}
packIcon={teaserPack.icon}
onClose={handleTeaserClose}
onUnlock={handleTeaserUnlock}
/>
)}
<Paywall
visible={showPaywall}
onClose={() => setShowPaywall(false)}
onSuccess={() => {
// Refresh packs after successful purchase
fetchPacks();
}}
/>
</View>
</GradientBackground>
);
}
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",
},
});
+15
View File
@@ -0,0 +1,15 @@
import { Stack } from "expo-router";
import { colors } from "../../../src/theme";
export default function PacksLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
animation: "fade",
animationDuration: 150,
}}
/>
);
}
+297
View File
@@ -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 (
<GradientBackground>
<View style={styles.container}>
{/* Header */}
<Animated.View
entering={FadeInDown.duration(600).springify()}
style={styles.header}
>
<Text style={styles.label}>EXPLORE</Text>
<Text style={styles.title}>Content Packs</Text>
{/* Decorative Separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<Text style={styles.subtitle}>
Choose a pack type to explore
</Text>
</Animated.View>
{/* Pack Type Cards */}
<View style={styles.cardsContainer}>
{PACK_TYPES.map((packType, index) => (
<Animated.View
key={packType.id}
entering={FadeInDown.delay(100 + index * 100).duration(500).springify()}
>
<Pressable
onPress={() => handlePackTypePress(packType)}
style={({ pressed }) => [
styles.card,
pressed && !packType.comingSoon && styles.cardPressed,
packType.comingSoon && styles.cardDisabled,
]}
disabled={packType.comingSoon}
>
<LinearGradient
colors={packType.gradient as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.cardGradient}
>
{/* Icon Circle */}
<View
style={[
styles.iconCircle,
{
backgroundColor: `${packType.colorRgba}0.15)`,
borderColor: `${packType.colorRgba}0.3)`,
},
]}
>
<Ionicons
name={packType.icon}
size={32}
color={packType.comingSoon ? colors.textTertiary : packType.color}
/>
</View>
{/* Content */}
<View style={styles.cardContent}>
<View style={styles.cardTitleRow}>
<Text
style={[
styles.cardTitle,
packType.comingSoon && styles.cardTitleDisabled,
]}
>
{packType.title}
</Text>
{packType.comingSoon && (
<View style={styles.comingSoonBadge}>
<Text style={styles.comingSoonText}>Coming Soon</Text>
</View>
)}
</View>
<Text
style={[
styles.cardDescription,
packType.comingSoon && styles.cardDescriptionDisabled,
]}
>
{packType.description}
</Text>
</View>
{/* Arrow */}
{!packType.comingSoon && (
<Ionicons
name="chevron-forward"
size={24}
color={colors.textSecondary}
/>
)}
</LinearGradient>
</Pressable>
</Animated.View>
))}
</View>
</View>
</GradientBackground>
);
}
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,
},
});
+711
View File
@@ -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 (
<Pressable onPress={onPress} style={styles.packRow}>
<View style={styles.packRowMain}>
<View style={[styles.packIcon, isEnabled && styles.packIconEnabled]}>
<Text style={styles.packIconText}>{pack.icon || "📦"}</Text>
</View>
<View style={styles.packContent}>
<View style={styles.packTitleRow}>
<Text style={styles.packName} numberOfLines={1}>{pack.name}</Text>
{isPremiumLocked && (
<Ionicons name="lock-closed" size={14} color={colors.premium.gold} />
)}
</View>
<Text style={styles.packMeta}>
{questionCount} {questionCount === 1 ? "question" : "questions"}
{isEnabled && <Text style={styles.packMetaEnabled}> · Enabled</Text>}
</Text>
</View>
<Ionicons
name={isExpanded ? "chevron-up" : "chevron-down"}
size={20}
color={colors.textTertiary}
/>
</View>
{isExpanded && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={styles.packExpanded}
>
{pack.description && (
<Text style={styles.packDescription}>{pack.description}</Text>
)}
<View style={styles.packActions}>
<Pressable style={styles.packInfoButton} onPress={onInfoPress}>
<Ionicons name="eye-outline" size={18} color={colors.textSecondary} />
<Text style={styles.packInfoButtonText}>Preview</Text>
</Pressable>
<Pressable
style={[
styles.packToggleButton,
isEnabled && styles.packToggleButtonEnabled,
isPremiumLocked && styles.packToggleButtonLocked,
]}
onPress={onToggle}
>
{isPremiumLocked ? (
<>
<Ionicons name="lock-closed" size={16} color={colors.premium.gold} />
<Text style={[styles.packToggleButtonText, styles.packToggleButtonTextLocked]}>
Unlock
</Text>
</>
) : isEnabled ? (
<>
<Ionicons name="checkmark" size={16} color={colors.primary} />
<Text style={[styles.packToggleButtonText, styles.packToggleButtonTextEnabled]}>
Enabled
</Text>
</>
) : (
<>
<Ionicons name="add" size={16} color={colors.text} />
<Text style={styles.packToggleButtonText}>Enable</Text>
</>
)}
</Pressable>
</View>
</Animated.View>
)}
</Pressable>
);
}
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 (
<View style={styles.categorySection}>
<Pressable style={styles.categoryHeader} onPress={handleToggleCollapse}>
<View style={styles.categoryHeaderLeft}>
{category.icon && (
<Text style={styles.categoryIcon}>{category.icon}</Text>
)}
<Text style={styles.categoryName}>{category.name}</Text>
{enabledCount > 0 && (
<View style={styles.categoryBadge}>
<Text style={styles.categoryBadgeText}>{enabledCount}</Text>
</View>
)}
</View>
<Animated.View style={chevronStyle}>
<Ionicons name="chevron-down" size={20} color={colors.textSecondary} />
</Animated.View>
</Pressable>
{!isCollapsed && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={styles.packsList}
>
{packs.map((pack) => (
<PackRow
key={pack.id}
pack={pack}
isEnabled={enabledPackIds.includes(pack.id)}
isPremiumLocked={pack.is_premium && !hasPremiumAccess}
isExpanded={expandedPackId === pack.id}
onPress={() => onPackPress(pack.id)}
onToggle={() => onPackToggle(pack)}
onInfoPress={() => onPackInfo(pack)}
/>
))}
</Animated.View>
)}
</View>
);
}
export default function QuestionPacksScreen() {
const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
const { user, partner } = useAuthStore();
const { subscription } = useSubscriptionStore();
const [expandedPackId, setExpandedPackId] = useState<string | null>(null);
const [showPaywall, setShowPaywall] = useState(false);
const [showTeaser, setShowTeaser] = useState(false);
const [teaserPack, setTeaserPack] = useState<QuestionPack | null>(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<string, QuestionPack[]>();
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 (
<GradientBackground>
<View style={styles.container}>
{/* Fixed Nav Bar */}
<View style={styles.navBar}>
{/* Nav bar background with blur */}
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
) : (
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
)}
</Animated.View>
{/* Back button */}
<Pressable
style={styles.backButton}
onPress={() => router.back()}
hitSlop={8}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</Pressable>
{/* Compact title (fades in on scroll) */}
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]}>
Question Packs
</Animated.Text>
{/* Spacer for alignment */}
<View style={styles.navBarSpacer} />
</View>
{/* Scrollable Content */}
<Animated.ScrollView
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
refreshControl={
<RefreshControl
refreshing={isLoading}
onRefresh={fetchPacks}
tintColor={colors.primary}
colors={[colors.primary]}
progressViewOffset={HEADER_MAX_HEIGHT}
/>
}
contentContainerStyle={styles.scrollContent}
>
{/* Large Header (fades out on scroll) */}
<Animated.View style={[styles.largeHeader, largeHeaderStyle]}>
<Text style={styles.label}>COLLECTION</Text>
<Text style={styles.title}>Question Packs</Text>
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<View style={styles.statsBadge}>
<Ionicons name="checkmark-circle" size={14} color={colors.primary} />
<Text style={styles.statsBadgeText}>
{totalQuestions} {totalQuestions === 1 ? "question" : "questions"} from {enabledPackCount} {enabledPackCount === 1 ? "pack" : "packs"}
</Text>
</View>
</Animated.View>
{/* Categories */}
<View style={styles.categoriesContainer}>
{categories.map(category => {
const categoryPacks = packsByCategory.get(category.id) || [];
if (categoryPacks.length === 0) return null;
return (
<CategorySection
key={category.id}
category={category}
packs={categoryPacks}
enabledPackIds={enabledPackIds}
hasPremiumAccess={hasPremiumAccess}
expandedPackId={expandedPackId}
onPackPress={handlePackPress}
onPackToggle={handlePackToggle}
onPackInfo={handlePackInfo}
/>
);
})}
{(packsByCategory.get("uncategorized")?.length || 0) > 0 && (
<CategorySection
category={{ id: "uncategorized", name: "Other", description: null, icon: "📁", sort_order: 999, created_at: "" }}
packs={packsByCategory.get("uncategorized") || []}
enabledPackIds={enabledPackIds}
hasPremiumAccess={hasPremiumAccess}
expandedPackId={expandedPackId}
onPackPress={handlePackPress}
onPackToggle={handlePackToggle}
onPackInfo={handlePackInfo}
/>
)}
</View>
</Animated.ScrollView>
{/* Modals */}
{teaserPack && (
<PackTeaser
visible={showTeaser}
packId={teaserPack.id}
packName={teaserPack.name}
packIcon={teaserPack.icon}
onClose={handleTeaserClose}
onUnlock={handleTeaserUnlock}
/>
)}
<Paywall
visible={showPaywall}
onClose={() => setShowPaywall(false)}
onSuccess={() => fetchPacks()}
/>
</View>
</GradientBackground>
);
}
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,
},
});
+1 -1
View File
@@ -408,7 +408,7 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
}, },
header: { header: {
paddingTop: Platform.OS === "ios" ? 60 : 40, paddingTop: 60,
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg, paddingBottom: spacing.lg,
flexDirection: "row", flexDirection: "row",
+57 -20
View File
@@ -22,9 +22,11 @@ import { GradientBackground, GlassCard } from "../../src/components/ui";
import { FeedbackModal } from "../../src/components/FeedbackModal"; import { FeedbackModal } from "../../src/components/FeedbackModal";
import { Paywall } from "../../src/components/Paywall"; import { Paywall } from "../../src/components/Paywall";
import { Events } from "../../src/lib/analytics"; 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 MAX_CONTENT_WIDTH = 500;
const ACCENT_COLOR = featureColors.profile.accent;
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
export default function ProfileScreen() { export default function ProfileScreen() {
const { user, partner, couple, signOut, fetchCouple, fetchUser } = useAuthStore(); const { user, partner, couple, signOut, fetchCouple, fetchUser } = useAuthStore();
@@ -447,7 +449,13 @@ export default function ProfileScreen() {
entering={FadeInDown.delay(100).duration(500)} entering={FadeInDown.delay(100).duration(500)}
style={styles.header} style={styles.header}
> >
<Text style={styles.headerLabel}>SETTINGS</Text>
<Text style={styles.title}>Profile</Text> <Text style={styles.title}>Profile</Text>
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
</Animated.View> </Animated.View>
{/* Profile Card */} {/* Profile Card */}
@@ -464,7 +472,7 @@ export default function ProfileScreen() {
style={styles.avatarTouchable} style={styles.avatarTouchable}
> >
<LinearGradient <LinearGradient
colors={gradients.primary as [string, string]} colors={ACCENT_GRADIENT}
style={styles.avatarGradient} style={styles.avatarGradient}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
@@ -560,7 +568,7 @@ export default function ProfileScreen() {
<View style={styles.rowContainer}> <View style={styles.rowContainer}>
<View style={styles.rowLeft}> <View style={styles.rowLeft}>
<LinearGradient <LinearGradient
colors={gradients.primary as [string, string]} colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient} style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
@@ -590,7 +598,7 @@ export default function ProfileScreen() {
> >
<View style={styles.rowLeft}> <View style={styles.rowLeft}>
<LinearGradient <LinearGradient
colors={gradients.primary as [string, string]} colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient} style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
@@ -640,7 +648,7 @@ export default function ProfileScreen() {
<View style={styles.rowContainer}> <View style={styles.rowContainer}>
<View style={styles.rowLeft}> <View style={styles.rowLeft}>
<LinearGradient <LinearGradient
colors={gradients.primary as [string, string]} colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient} style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
@@ -683,7 +691,7 @@ export default function ProfileScreen() {
</View> </View>
</View> </View>
<LinearGradient <LinearGradient
colors={gradients.primary as [string, string]} colors={ACCENT_GRADIENT}
style={styles.upgradeButton} style={styles.upgradeButton}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
@@ -718,7 +726,7 @@ export default function ProfileScreen() {
<View style={styles.rowContainer}> <View style={styles.rowContainer}>
<View style={styles.rowLeft}> <View style={styles.rowLeft}>
<LinearGradient <LinearGradient
colors={gradients.primary as [string, string]} colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient} style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
@@ -734,8 +742,8 @@ export default function ProfileScreen() {
value={showExplicit} value={showExplicit}
onValueChange={handleExplicitToggle} onValueChange={handleExplicitToggle}
disabled={isUpdatingExplicit} disabled={isUpdatingExplicit}
trackColor={{ false: colors.glass.border, true: colors.primaryLight }} trackColor={{ false: colors.glass.border, true: colors.secondaryLight }}
thumbColor={showExplicit ? colors.primary : colors.textTertiary} thumbColor={showExplicit ? ACCENT_COLOR : colors.textTertiary}
ios_backgroundColor={colors.glass.border} ios_backgroundColor={colors.glass.border}
/> />
</View> </View>
@@ -745,7 +753,7 @@ export default function ProfileScreen() {
<View style={styles.rowContainer}> <View style={styles.rowContainer}>
<View style={styles.rowLeft}> <View style={styles.rowLeft}>
<LinearGradient <LinearGradient
colors={gradients.primary as [string, string]} colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient} style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
@@ -765,8 +773,8 @@ export default function ProfileScreen() {
value={biometricEnabled} value={biometricEnabled}
onValueChange={handleBiometricToggle} onValueChange={handleBiometricToggle}
disabled={isUpdatingBiometric} disabled={isUpdatingBiometric}
trackColor={{ false: colors.glass.border, true: colors.primaryLight }} trackColor={{ false: colors.glass.border, true: colors.secondaryLight }}
thumbColor={biometricEnabled ? colors.primary : colors.textTertiary} thumbColor={biometricEnabled ? ACCENT_COLOR : colors.textTertiary}
ios_backgroundColor={colors.glass.border} ios_backgroundColor={colors.glass.border}
/> />
</View> </View>
@@ -812,7 +820,7 @@ export default function ProfileScreen() {
> >
<View style={styles.rowLeft}> <View style={styles.rowLeft}>
<LinearGradient <LinearGradient
colors={gradients.primary as [string, string]} colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient} style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
@@ -997,7 +1005,7 @@ export default function ProfileScreen() {
style={styles.versionContainer} style={styles.versionContainer}
> >
<View style={styles.versionBadge}> <View style={styles.versionBadge}>
<Ionicons name="heart" size={12} color={colors.primary} /> <Ionicons name="heart" size={12} color={ACCENT_COLOR} />
<Text style={styles.version}>Sauci v1.0.0</Text> <Text style={styles.version}>Sauci v1.0.0</Text>
</View> </View>
</Animated.View> </Animated.View>
@@ -1125,11 +1133,40 @@ const styles = StyleSheet.create({
header: { header: {
paddingTop: 60, paddingTop: 60,
paddingHorizontal: spacing.lg, 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: { title: {
...typography.title1, ...typography.largeTitle,
color: colors.text, 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: { profileSection: {
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
@@ -1186,7 +1223,7 @@ const styles = StyleSheet.create({
width: 32, width: 32,
height: 32, height: 32,
borderRadius: 16, borderRadius: 16,
backgroundColor: colors.primary, backgroundColor: colors.secondary,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
borderWidth: 3, borderWidth: 3,
@@ -1227,7 +1264,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.glass.background, backgroundColor: colors.glass.background,
borderRadius: radius.md, borderRadius: radius.md,
borderWidth: 1, borderWidth: 1,
borderColor: colors.primary, borderColor: colors.secondary,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
minWidth: 200, minWidth: 200,
@@ -1250,7 +1287,7 @@ const styles = StyleSheet.create({
width: 36, width: 36,
height: 36, height: 36,
borderRadius: 18, borderRadius: 18,
backgroundColor: colors.primary, backgroundColor: colors.secondary,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}, },
@@ -1364,7 +1401,7 @@ const styles = StyleSheet.create({
}, },
manageButtonText: { manageButtonText: {
...typography.subhead, ...typography.subhead,
color: colors.primary, color: colors.secondary,
fontWeight: "600", fontWeight: "600",
}, },
upgradeButton: { upgradeButton: {
+6 -22
View File
@@ -33,11 +33,6 @@ export default function QuizScreen() {
entering={FadeInDown.duration(600).springify()} entering={FadeInDown.duration(600).springify()}
style={styles.content} style={styles.content}
> >
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name="heart" size={36} color={ACCENT_COLOR} />
</View>
{/* Title section */} {/* Title section */}
<Text style={styles.label}>INTIMACY</Text> <Text style={styles.label}>INTIMACY</Text>
<Text style={styles.title}>Quiz</Text> <Text style={styles.title}>Quiz</Text>
@@ -81,12 +76,10 @@ export default function QuizScreen() {
{/* Bottom teaser */} {/* Bottom teaser */}
<Text style={styles.teaser}>Something special is being crafted for you</Text> <Text style={styles.teaser}>Something special is being crafted for you</Text>
{/* Interest button */} {/* Interest button - always reserve space to prevent layout shift */}
<View style={styles.interestButtonContainer}>
{isAuthenticated && !isLoading && ( {isAuthenticated && !isLoading && (
<Animated.View <Animated.View entering={FadeIn.delay(500).duration(400)}>
entering={FadeIn.delay(500).duration(400)}
style={styles.interestButtonContainer}
>
<AnimatedPressable <AnimatedPressable
onPress={toggleInterest} onPress={toggleInterest}
onPressIn={handlePressIn} onPressIn={handlePressIn}
@@ -115,6 +108,7 @@ export default function QuizScreen() {
</AnimatedPressable> </AnimatedPressable>
</Animated.View> </Animated.View>
)} )}
</View>
</Animated.View> </Animated.View>
</View> </View>
</GradientBackground> </GradientBackground>
@@ -127,7 +121,7 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingTop: Platform.OS === 'ios' ? 60 : 40, paddingTop: 60,
paddingBottom: Platform.OS === 'ios' ? 120 : 100, paddingBottom: Platform.OS === 'ios' ? 120 : 100,
}, },
content: { content: {
@@ -135,17 +129,6 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
paddingHorizontal: spacing.md, 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: { label: {
...typography.caption1, ...typography.caption1,
fontWeight: '600', fontWeight: '600',
@@ -223,6 +206,7 @@ const styles = StyleSheet.create({
}, },
interestButtonContainer: { interestButtonContainer: {
marginTop: spacing.xl, marginTop: spacing.xl,
minHeight: 44,
}, },
interestButton: { interestButton: {
flexDirection: 'row', flexDirection: 'row',
+67 -31
View File
@@ -19,7 +19,8 @@ import { usePacksStore, useAuthStore } from "../../src/store";
import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions"; import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions";
import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen"; import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen";
import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler"; 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 SwipeTutorial from "../../src/components/SwipeTutorial";
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui"; import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
import { Events } from "../../src/lib/analytics"; import { Events } from "../../src/lib/analytics";
@@ -115,6 +116,7 @@ export default function SwipeScreen() {
const checkAnswerGap = async (): Promise<boolean> => { const checkAnswerGap = async (): Promise<boolean> => {
if (!couple || !partner) { if (!couple || !partner) {
setIsBlocked(false); setIsBlocked(false);
setGapInfo(null);
return false; return false;
} }
@@ -129,10 +131,15 @@ export default function SwipeScreen() {
if (data && data.length > 0) { if (data && data.length > 0) {
const result = data[0]; const result = data[0];
setIsBlocked(result.is_blocked); setIsBlocked(result.is_blocked);
// Only set gap info if threshold is enabled (> 0)
if (result.threshold > 0) {
setGapInfo({ setGapInfo({
unanswered: result.unanswered_by_partner, unanswered: result.unanswered_by_partner,
threshold: result.threshold threshold: result.threshold
}); });
} else {
setGapInfo(null);
}
return result.is_blocked; return result.is_blocked;
} }
} catch (error) { } catch (error) {
@@ -141,6 +148,18 @@ export default function SwipeScreen() {
return false; 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 // Track when all questions are exhausted
useEffect(() => { useEffect(() => {
if (questions.length > 0 && currentIndex >= questions.length && !hasTrackedExhausted.current) { if (questions.length > 0 && currentIndex >= questions.length && !hasTrackedExhausted.current) {
@@ -193,12 +212,14 @@ export default function SwipeScreen() {
// Filter out recently skipped questions // Filter out recently skipped questions
const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id)); 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 // Always check answer gap when user has partner and not viewing specific pack
if (filtered.length === 0 && partner && !packId) { // This gives us the gap info for accurate counter display
if (partner && !packId) {
await checkAnswerGap(); await checkAnswerGap();
} else { } else {
// Reset blocked state if we have questions // Reset blocked state if no partner or viewing specific pack
setIsBlocked(false); setIsBlocked(false);
setGapInfo(null);
} }
const sorted = filtered.sort((a: any, b: any) => { const sorted = filtered.sort((a: any, b: any) => {
@@ -554,10 +575,10 @@ export default function SwipeScreen() {
return ( return (
<GradientBackground> <GradientBackground>
{/* Ambient Orbs */} {/* Ambient Orbs - Premium gold/rose */}
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none"> <Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
<LinearGradient <LinearGradient
colors={[colors.primaryGlow, 'transparent']} colors={[colors.premium.goldGlow, 'transparent']}
style={styles.orbGradient} style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }} start={{ x: 0.5, y: 0.5 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
@@ -565,7 +586,7 @@ export default function SwipeScreen() {
</Animated.View> </Animated.View>
<Animated.View style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]} pointerEvents="none"> <Animated.View style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]} pointerEvents="none">
<LinearGradient <LinearGradient
colors={[colors.secondaryGlow, 'transparent']} colors={['rgba(232, 164, 174, 0.2)', 'transparent']}
style={styles.orbGradient} style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }} start={{ x: 0.5, y: 0.5 }}
end={{ x: 0, y: 0 }} end={{ x: 0, y: 0 }}
@@ -579,26 +600,23 @@ export default function SwipeScreen() {
style={styles.header} style={styles.header}
> >
<View style={styles.progressContainer}> <View style={styles.progressContainer}>
{/* Premium label */}
<Text style={styles.progressLabel}>EXPLORE</Text>
<Text style={styles.progressText}> <Text style={styles.progressText}>
{currentIndex + 1} of {questions.length} {currentIndex + 1} of {effectiveTotal || questions.length}
</Text> </Text>
<View style={[styles.progressBar, styles.progressBarEnhanced]}> <View style={[styles.progressBar, styles.progressBarPremium]}>
<Animated.View <Animated.View
style={[ style={[
styles.progressFill, styles.progressFill,
{ width: `${((currentIndex + 1) / questions.length) * 100}%` } styles.progressFillPremium,
{ width: `${((currentIndex + 1) / (effectiveTotal || questions.length || 1)) * 100}%` }
]} ]}
> >
<LinearGradient
colors={gradients.primary as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.progressGradient}
/>
{/* Shimmer sweep */} {/* Shimmer sweep */}
<Animated.View style={[styles.progressShimmer, shimmerStyle]}> <Animated.View style={[styles.progressShimmer, shimmerStyle]}>
<LinearGradient <LinearGradient
colors={['transparent', 'rgba(255,255,255,0.3)', 'transparent']} colors={['transparent', 'rgba(212, 175, 55, 0.5)', 'transparent']}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
@@ -629,12 +647,12 @@ export default function SwipeScreen() {
/> />
</View> </View>
{/* Hint */} {/* Hint - Premium styling */}
<Animated.View <Animated.View
entering={FadeIn.delay(500).duration(400)} entering={FadeIn.delay(500).duration(400)}
style={styles.hintContainer} style={styles.hintContainer}
> >
<Text style={styles.hintText}>Swipe or tap to answer</Text> <Text style={styles.hintTextPremium}>Swipe or tap to answer</Text>
</Animated.View> </Animated.View>
{/* Bottom spacing for tab bar */} {/* Bottom spacing for tab bar */}
@@ -686,6 +704,13 @@ const styles = StyleSheet.create({
progressContainer: { progressContainer: {
alignItems: "center", alignItems: "center",
}, },
progressLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 3,
color: colors.premium.gold,
marginBottom: spacing.xs,
},
progressText: { progressText: {
...typography.subhead, ...typography.subhead,
color: colors.textSecondary, color: colors.textSecondary,
@@ -698,18 +723,23 @@ const styles = StyleSheet.create({
borderRadius: 3, borderRadius: 3,
overflow: "hidden", overflow: "hidden",
}, },
progressBarEnhanced: { progressBarPremium: {
height: 8, width: 140,
borderRadius: 4, height: 4,
borderRadius: 2,
backgroundColor: 'rgba(212, 175, 55, 0.1)',
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.borderLight, borderColor: 'rgba(212, 175, 55, 0.15)',
...shadows.glow(colors.primaryGlow),
}, },
progressFill: { progressFill: {
height: "100%", height: "100%",
borderRadius: 4, borderRadius: 4,
overflow: "hidden", overflow: "hidden",
}, },
progressFillPremium: {
backgroundColor: 'rgba(212, 175, 55, 0.5)',
borderRadius: 2,
},
progressGradient: { progressGradient: {
flex: 1, flex: 1,
borderRadius: 4, borderRadius: 4,
@@ -725,24 +755,24 @@ const styles = StyleSheet.create({
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}, },
// Card stack // Card stack - Premium styling
stackCard: { stackCard: {
position: "absolute", position: "absolute",
width: "85%", width: "85%",
height: 480, height: 500,
backgroundColor: colors.glass.backgroundLight, backgroundColor: '#0d0d1a',
borderRadius: radius.xxl, borderRadius: radius.xl,
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.border, borderColor: 'rgba(212, 175, 55, 0.15)',
}, },
stackCardSecond: { stackCardSecond: {
transform: [{ scale: 0.95 }, { translateY: 12 }], transform: [{ scale: 0.95 }, { translateY: 12 }],
opacity: 0.5, opacity: 0.4,
...shadows.md, ...shadows.md,
}, },
stackCardThird: { stackCardThird: {
transform: [{ scale: 0.90 }, { translateY: 24 }], transform: [{ scale: 0.90 }, { translateY: 24 }],
opacity: 0.25, opacity: 0.2,
...shadows.sm, ...shadows.sm,
}, },
hintContainer: { hintContainer: {
@@ -753,6 +783,12 @@ const styles = StyleSheet.create({
...typography.caption1, ...typography.caption1,
color: colors.textTertiary, color: colors.textTertiary,
}, },
hintTextPremium: {
...typography.caption1,
fontStyle: 'italic',
color: colors.textTertiary,
letterSpacing: 0.5,
},
bottomSpacer: { bottomSpacer: {
height: Platform.OS === 'ios' ? 100 : 80, height: Platform.OS === 'ios' ? 100 : 80,
}, },
+415 -191
View File
@@ -6,9 +6,16 @@ import { BlurView } from "expo-blur";
import { supabase } from "../../src/lib/supabase"; import { supabase } from "../../src/lib/supabase";
import { usePacksStore, useSubscriptionStore, useAuthStore } from "../../src/store"; import { usePacksStore, useSubscriptionStore, useAuthStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeInDown, FadeIn } from "react-native-reanimated"; import Animated, {
import { GradientBackground, GlassCard, ShimmerEffect } from "../../src/components/ui"; FadeInDown,
import { colors, gradients, spacing, radius, typography, blur, shadows } from "../../src/theme"; 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"; import { Question } from "../../src/types";
type TooltipType = 'initiator' | 'coupleType' | null; type TooltipType = 'initiator' | 'coupleType' | null;
@@ -24,13 +31,14 @@ const TOOLTIP_CONTENT = {
}, },
}; };
// Boutique gradient for question cards const ACCENT_COLOR = colors.primary;
const QUESTION_GRADIENTS: Array<readonly [string, string]> = [
['rgba(233, 69, 96, 0.15)', 'rgba(155, 89, 182, 0.15)'], const NAV_BAR_HEIGHT = 44;
['rgba(155, 89, 182, 0.15)', 'rgba(233, 69, 96, 0.15)'], const STATUS_BAR_HEIGHT = 60;
['rgba(183, 110, 121, 0.12)', 'rgba(139, 69, 87, 0.12)'], const HERO_HEIGHT = 260;
['rgba(142, 68, 173, 0.12)', 'rgba(44, 62, 80, 0.12)'], const HEADER_SCROLL_DISTANCE = 120;
];
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList<Question>);
export default function PackDetailsScreen() { export default function PackDetailsScreen() {
const { id } = useLocalSearchParams(); const { id } = useLocalSearchParams();
@@ -42,11 +50,12 @@ export default function PackDetailsScreen() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [activeTooltip, setActiveTooltip] = useState<TooltipType>(null); const [activeTooltip, setActiveTooltip] = useState<TooltipType>(null);
const scrollY = useSharedValue(0);
const pack = packs.find((p) => p.id === id); const pack = packs.find((p) => p.id === id);
const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser; const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser;
const isPremium = pack?.is_premium && hasPremiumAccess; const isPremium = pack?.is_premium && hasPremiumAccess;
// Get category for breadcrumb
const { categories } = usePacksStore(); const { categories } = usePacksStore();
const category = categories.find(c => c.id === pack?.category_id); 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 useBlur = Platform.OS === 'ios';
const renderItem = ({ item, index }: { item: Question; index: number }) => { const renderItem = ({ item, index }: { item: Question; index: number }) => {
const cardGradient = QUESTION_GRADIENTS[index % QUESTION_GRADIENTS.length];
return ( return (
<Animated.View entering={FadeInDown.delay(index * 50).duration(300)}> <Animated.View entering={FadeInDown.delay(index * 50).duration(300)}>
<GlassCard style={styles.questionCard}> <View style={styles.questionCard}>
{/* Subtle gradient overlay */}
<LinearGradient
colors={cardGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.questionContent}> <View style={styles.questionContent}>
<Text style={styles.questionNumber}>#{index + 1}</Text>
<Text style={styles.questionText}>{item.text}</Text> <Text style={styles.questionText}>{item.text}</Text>
{item.partner_text && ( {item.partner_text && (
@@ -101,41 +144,37 @@ export default function PackDetailsScreen() {
)} )}
<View style={styles.footerContainer}> <View style={styles.footerContainer}>
{/* Intensity */}
<View style={styles.intensityContainer}> <View style={styles.intensityContainer}>
{[...Array(5)].map((_, i) => ( {[...Array(5)].map((_, i) => (
<Ionicons <Ionicons
key={i} key={i}
name="flame" name="flame"
size={14} size={12}
color={i < item.intensity ? colors.primary : colors.textTertiary} color={i < item.intensity ? ACCENT_COLOR : 'rgba(255, 255, 255, 0.15)'}
style={{ marginRight: 2 }} style={{ marginRight: 2 }}
/> />
))} ))}
</View> </View>
<View style={styles.badgesContainer}> <View style={styles.badgesContainer}>
{/* Initiator indicator */}
{item.target_user_genders && item.target_user_genders.length > 0 && ( {item.target_user_genders && item.target_user_genders.length > 0 && (
<Pressable <Pressable
style={styles.targetBadge} style={styles.targetBadge}
onPress={() => setActiveTooltip('initiator')} onPress={() => setActiveTooltip('initiator')}
> >
<Ionicons name="arrow-forward" size={12} color={colors.premium.gold} style={{ marginRight: 4 }} /> <Ionicons name="arrow-forward" size={10} color={colors.textTertiary} style={{ marginRight: 2 }} />
{item.target_user_genders.map((g, i) => ( {item.target_user_genders.map((g, i) => (
<Ionicons <Ionicons
key={g} key={g}
name={g === 'male' ? 'male' : g === 'female' ? 'female' : 'person'} name={g === 'male' ? 'male' : g === 'female' ? 'female' : 'person'}
size={14} size={12}
color={colors.premium.gold} color={colors.textTertiary}
style={i > 0 ? { marginLeft: 4 } : {}} style={i > 0 ? { marginLeft: 2 } : {}}
/> />
))} ))}
<Ionicons name="help-circle-outline" size={12} color={colors.premium.gold} style={{ marginLeft: 4 }} />
</Pressable> </Pressable>
)} )}
{/* Couple type indicator */}
<Pressable <Pressable
style={styles.targetBadge} style={styles.targetBadge}
onPress={() => setActiveTooltip('coupleType')} onPress={() => setActiveTooltip('coupleType')}
@@ -157,8 +196,8 @@ export default function PackDetailsScreen() {
<Ionicons <Ionicons
key={i} key={i}
name={icon} name={icon}
size={14} size={12}
color={colors.textSecondary} color={colors.textTertiary}
style={i > 0 ? { marginLeft: 2 } : {}} style={i > 0 ? { marginLeft: 2 } : {}}
/> />
))} ))}
@@ -166,18 +205,109 @@ export default function PackDetailsScreen() {
); );
}) })
) : ( ) : (
<Ionicons name="people" size={14} color={colors.textSecondary} /> <Ionicons name="people" size={12} color={colors.textTertiary} />
)} )}
<Ionicons name="help-circle-outline" size={12} color={colors.textTertiary} style={{ marginLeft: 4 }} />
</Pressable> </Pressable>
</View> </View>
</View> </View>
</View> </View>
</GlassCard> </View>
</Animated.View> </Animated.View>
); );
}; };
const heroContent = (
<View style={styles.heroInner}>
{/* Premium Banner */}
{pack?.is_premium && (
<View style={styles.premiumBanner}>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.premiumBannerContent}>
<Ionicons name="diamond" size={16} color={colors.background} />
<Text style={styles.premiumBannerText}>PREMIUM COLLECTION</Text>
<Ionicons name="diamond" size={16} color={colors.background} />
</View>
</View>
)}
<View style={styles.heroContent}>
{/* Category label */}
<Text style={[styles.label, pack?.is_premium && styles.labelPremium]}>
{category ? category.name.toUpperCase() : 'PREVIEW'}
</Text>
{/* Icon with enhanced glow for premium */}
<View style={styles.iconWrapper}>
{pack?.is_premium && (
<>
<View style={styles.iconGlowOuter} />
<View style={styles.iconGlow} />
</>
)}
<View style={[
styles.iconContainer,
pack?.is_premium && styles.iconContainerPremium
]}>
{pack?.is_premium && (
<LinearGradient
colors={['rgba(212, 175, 55, 0.2)', 'rgba(184, 134, 11, 0.1)']}
style={StyleSheet.absoluteFill}
/>
)}
<Text style={styles.emoji}>{pack?.icon || "📦"}</Text>
</View>
{/* Crown for premium */}
{pack?.is_premium && (
<View style={styles.crownBadge}>
<Ionicons name="star" size={14} color={colors.premium.gold} />
</View>
)}
</View>
{/* Title */}
<Text style={[styles.title, pack?.is_premium && styles.titlePremium]}>
{pack?.name || "Pack Details"}
</Text>
{/* Premium badge - larger and more prominent */}
{pack?.is_premium && (
<View style={styles.proBadge}>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[StyleSheet.absoluteFill, { borderRadius: radius.full }]}
/>
<Ionicons name="star" size={12} color={colors.background} />
<Text style={styles.proBadgeText}>PREMIUM</Text>
<Ionicons name="star" size={12} color={colors.background} />
</View>
)}
{/* Decorative separator */}
<View style={styles.separator}>
<View style={[styles.separatorLine, pack?.is_premium && styles.separatorLinePremium]} />
<View style={[styles.separatorDiamond, pack?.is_premium && styles.separatorDiamondPremium]} />
<View style={[styles.separatorLine, pack?.is_premium && styles.separatorLinePremium]} />
</View>
{/* Description */}
<Text style={styles.description}>{pack?.description}</Text>
{/* Stats badge */}
<View style={[styles.statsBadge, pack?.is_premium && styles.statsBadgePremium]}>
<Ionicons name="help-circle-outline" size={14} color={pack?.is_premium ? colors.premium.gold : ACCENT_COLOR} />
<Text style={[styles.statsText, pack?.is_premium && styles.statsTextPremium]}>{questions.length} questions</Text>
</View>
</View>
</View>
);
if (isLoading) { if (isLoading) {
return ( return (
<GradientBackground> <GradientBackground>
@@ -188,103 +318,46 @@ export default function PackDetailsScreen() {
); );
} }
const heroContent = (
<View style={styles.heroInner}>
{/* Background gradient */}
<LinearGradient
colors={isPremium ? gradients.boutiqueGold as [string, string] : gradients.boutiqueRose as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
{/* Glass overlay */}
{useBlur ? (
<BlurView
intensity={blur.light}
tint="dark"
style={[StyleSheet.absoluteFill, styles.heroBlur]}
/>
) : (
<View style={[StyleSheet.absoluteFill, styles.heroBlurFallback]} />
)}
{/* Silk texture */}
<LinearGradient
colors={gradients.silkLight as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[StyleSheet.absoluteFill, { opacity: 0.5 }]}
/>
{/* Back button */}
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<View style={styles.backButtonInner}>
<Ionicons name="arrow-back" size={22} color={colors.text} />
</View>
</TouchableOpacity>
{/* Pack info */}
<View style={styles.heroContent}>
<View style={[
styles.iconContainer,
isPremium && styles.iconContainerPremium
]}>
<Text style={styles.emoji}>{pack?.icon || "📦"}</Text>
</View>
{pack?.is_premium && (
<View style={styles.proBadge}>
<Ionicons name="star" size={12} color={colors.text} />
<Text style={styles.proBadgeText}>PRO</Text>
</View>
)}
{/* Category breadcrumb - tappable to filter by category */}
{category && (
<TouchableOpacity
style={styles.breadcrumb}
onPress={() => router.replace(`/(app)/packs?category=${category.id}`)}
activeOpacity={0.7}
>
<Text style={styles.breadcrumbText}>{category.icon} {category.name}</Text>
<Ionicons name="chevron-forward" size={12} color={colors.textTertiary} />
</TouchableOpacity>
)}
<Text style={styles.title}>{pack?.name || "Pack Details"}</Text>
<Text style={styles.description}>{pack?.description}</Text>
<View style={styles.statsBadge}>
<Text style={styles.statsText}>{questions.length} questions</Text>
</View>
</View>
</View>
);
return ( return (
<GradientBackground> <GradientBackground>
<View style={styles.container}> <View style={styles.container}>
{/* Hero Section */} {/* Fixed Nav Bar */}
<Animated.View entering={FadeIn.duration(400)}> <View style={styles.navBar}>
{isPremium ? ( <Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
<ShimmerEffect enabled={true} style={styles.heroContainer}> {Platform.OS === "ios" ? (
{heroContent} <BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
</ShimmerEffect>
) : ( ) : (
<View style={styles.heroContainer}> <View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
{heroContent}
</View>
)} )}
</Animated.View> </Animated.View>
{/* Questions List */} <TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<FlatList <View style={styles.backButtonInner}>
<Ionicons name="chevron-back" size={22} color={colors.text} />
</View>
</TouchableOpacity>
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]} numberOfLines={1}>
{pack?.name || "Pack Details"}
</Animated.Text>
<View style={styles.navBarSpacer} />
</View>
{/* Questions List with Hero Header */}
<AnimatedFlatList
data={questions} data={questions}
renderItem={renderItem} renderItem={renderItem}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
contentContainerStyle={styles.list}
ListHeaderComponent={
<Animated.View style={[styles.heroContainer, heroStyle]}>
{heroContent}
</Animated.View>
}
ListEmptyComponent={ ListEmptyComponent={
<Animated.View entering={FadeInDown.delay(100).duration(300)} style={styles.emptyContainer}> <Animated.View entering={FadeInDown.delay(100).duration(300)} style={styles.emptyContainer}>
<Ionicons name="help-circle-outline" size={48} color={colors.textTertiary} /> <Ionicons name="help-circle-outline" size={48} color={colors.textTertiary} />
@@ -345,92 +418,224 @@ const styles = StyleSheet.create({
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}, },
heroContainer: {
borderBottomLeftRadius: radius.xl, // Fixed Nav Bar
borderBottomRightRadius: radius.xl, navBar: {
overflow: 'hidden', position: "absolute",
...shadows.lg, 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: { navBarBackground: {
paddingTop: 50, ...StyleSheet.absoluteFillObject,
paddingBottom: spacing.lg, borderBottomWidth: 1,
minHeight: 260, borderBottomColor: 'rgba(233, 69, 96, 0.15)',
}, overflow: "hidden",
heroBlur: {
backgroundColor: 'rgba(22, 33, 62, 0.4)',
},
heroBlurFallback: {
backgroundColor: 'rgba(22, 33, 62, 0.6)',
}, },
backButton: { backButton: {
position: 'absolute',
top: 50,
left: spacing.md,
zIndex: 10, zIndex: 10,
}, },
backButtonInner: { backButtonInner: {
width: 40, width: 36,
height: 40, height: 36,
borderRadius: 20, borderRadius: 18,
backgroundColor: 'rgba(0, 0, 0, 0.3)', backgroundColor: 'rgba(255, 255, 255, 0.08)',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderWidth: 1, 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: { heroContent: {
alignItems: 'center', alignItems: 'center',
paddingHorizontal: spacing.lg, 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: { iconContainer: {
width: 80, width: 72,
height: 80, height: 72,
borderRadius: 40, borderRadius: 36,
backgroundColor: 'rgba(255, 255, 255, 0.1)', backgroundColor: 'rgba(255, 255, 255, 0.06)',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginBottom: spacing.md, borderWidth: 1,
borderWidth: 2, borderColor: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255, 255, 255, 0.15)', overflow: 'hidden',
}, },
iconContainerPremium: { iconContainerPremium: {
borderColor: colors.premium.gold, 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: { 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: { proBadge: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.premium.gold, paddingHorizontal: spacing.md,
paddingHorizontal: spacing.sm, paddingVertical: spacing.xs,
paddingVertical: 4, borderRadius: radius.full,
borderRadius: radius.sm, gap: spacing.xs,
gap: 4,
marginBottom: spacing.sm, marginBottom: spacing.sm,
overflow: 'hidden',
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 6,
elevation: 6,
}, },
proBadgeText: { proBadgeText: {
...typography.caption1, ...typography.caption1,
color: colors.text, color: colors.background,
fontWeight: '700', fontWeight: '800',
letterSpacing: 1.5,
}, },
breadcrumb: { separator: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 4, justifyContent: 'center',
marginBottom: spacing.xs, marginVertical: spacing.md,
width: 140,
}, },
breadcrumbText: { separatorLine: {
...typography.caption1, flex: 1,
color: colors.textSecondary, height: 1,
backgroundColor: 'rgba(233, 69, 96, 0.25)',
}, },
title: { separatorLinePremium: {
...typography.title2, backgroundColor: 'rgba(212, 175, 55, 0.4)',
color: colors.text, },
textAlign: 'center', separatorDiamond: {
marginBottom: spacing.xs, width: 6,
height: 6,
backgroundColor: ACCENT_COLOR,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
separatorDiamondPremium: {
backgroundColor: colors.premium.gold,
opacity: 1,
}, },
description: { description: {
...typography.body, ...typography.body,
@@ -438,37 +643,57 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
lineHeight: 24, lineHeight: 24,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
marginBottom: spacing.md,
}, },
statsBadge: { statsBadge: {
marginTop: spacing.md, flexDirection: 'row',
backgroundColor: 'rgba(255, 255, 255, 0.1)', alignItems: 'center',
paddingHorizontal: spacing.md, gap: spacing.xs,
paddingVertical: spacing.xs, backgroundColor: 'rgba(233, 69, 96, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full, borderRadius: radius.full,
borderWidth: 1, 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: { statsText: {
...typography.subhead, ...typography.subhead,
color: colors.textSecondary, color: colors.textSecondary,
fontWeight: '500', fontWeight: '500',
}, },
statsTextPremium: {
color: colors.premium.champagne,
},
// List
list: { list: {
padding: spacing.md, paddingHorizontal: spacing.md,
paddingBottom: Platform.OS === 'ios' ? 100 : 80, paddingBottom: Platform.OS === 'ios' ? 100 : 80,
}, },
questionCard: { questionCard: {
marginBottom: spacing.md, backgroundColor: 'rgba(22, 33, 62, 0.4)',
overflow: 'hidden', borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.06)',
marginBottom: spacing.sm,
}, },
questionContent: { questionContent: {
padding: spacing.md, padding: spacing.md,
}, },
questionNumber: {
...typography.caption2,
color: colors.textTertiary,
marginBottom: spacing.xs,
},
questionText: { questionText: {
...typography.body, ...typography.body,
color: colors.text, color: colors.text,
marginBottom: spacing.sm, marginBottom: spacing.sm,
lineHeight: 24, lineHeight: 22,
}, },
partnerTextContainer: { partnerTextContainer: {
flexDirection: 'row', flexDirection: 'row',
@@ -477,7 +702,7 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm, marginBottom: spacing.sm,
paddingVertical: spacing.xs, paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
backgroundColor: colors.premium.roseLight, backgroundColor: 'rgba(232, 164, 174, 0.1)',
borderRadius: radius.sm, borderRadius: radius.sm,
alignSelf: 'flex-start', alignSelf: 'flex-start',
}, },
@@ -491,23 +716,22 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
marginTop: spacing.xs, marginTop: spacing.xs,
paddingTop: spacing.xs,
borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.04)',
}, },
intensityContainer: { intensityContainer: {
flexDirection: 'row', flexDirection: 'row',
}, },
badgesContainer: { badgesContainer: {
flexDirection: 'row', flexDirection: 'row',
gap: spacing.sm, gap: spacing.xs,
}, },
targetBadge: { targetBadge: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.08)', paddingHorizontal: spacing.xs,
paddingHorizontal: spacing.sm, paddingVertical: 2,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.glass.border,
}, },
coupleIconGroup: { coupleIconGroup: {
flexDirection: 'row', flexDirection: 'row',
+90
View File
@@ -1,8 +1,21 @@
PODS: 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) - boost (1.84.0)
- DoubleConversion (1.1.6) - DoubleConversion (1.1.6)
- EXApplication (6.0.2): - EXApplication (6.0.2):
- ExpoModulesCore - ExpoModulesCore
- EXAV (15.0.2):
- ExpoModulesCore
- ReactCommon/turbomodule/core
- EXConstants (17.0.8): - EXConstants (17.0.8):
- ExpoModulesCore - ExpoModulesCore
- EXImageLoader (5.0.0): - EXImageLoader (5.0.0):
@@ -222,6 +235,10 @@ PODS:
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- Yoga - Yoga
- ExpoAdapterGoogleSignIn (16.1.1):
- ExpoModulesCore
- GoogleSignIn (~> 9.0)
- React-Core
- ExpoAppleAuthentication (7.1.3): - ExpoAppleAuthentication (7.1.3):
- ExpoModulesCore - ExpoModulesCore
- ExpoAsset (11.0.5): - ExpoAsset (11.0.5):
@@ -285,6 +302,24 @@ PODS:
- FBLazyVector (0.76.9) - FBLazyVector (0.76.9)
- fmt (11.0.2) - fmt (11.0.2)
- glog (0.3.5) - 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 (0.76.9):
- hermes-engine/Pre-built (= 0.76.9) - hermes-engine/Pre-built (= 0.76.9)
- hermes-engine/Pre-built (0.76.9) - hermes-engine/Pre-built (0.76.9)
@@ -293,6 +328,7 @@ PODS:
- libavif/core - libavif/core
- libdav1d (>= 0.6.0) - libdav1d (>= 0.6.0)
- libdav1d (1.2.0) - libdav1d (1.2.0)
- PromisesObjC (2.4.0)
- PurchasesHybridCommon (17.25.0): - PurchasesHybridCommon (17.25.0):
- RevenueCat (= 5.51.1) - RevenueCat (= 5.51.1)
- RCT-Folly (2024.10.14.00): - RCT-Folly (2024.10.14.00):
@@ -1573,6 +1609,27 @@ PODS:
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- Yoga - 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-native-safe-area-context (4.12.0):
- React-Core - React-Core
- React-nativeconfig (0.76.9) - React-nativeconfig (0.76.9)
@@ -1871,6 +1928,9 @@ PODS:
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- Yoga - Yoga
- RNGoogleSignin (16.1.1):
- GoogleSignIn (~> 9.0)
- React-Core
- RNPurchases (9.6.12): - RNPurchases (9.6.12):
- PurchasesHybridCommon (= 17.25.0) - PurchasesHybridCommon (= 17.25.0)
- React-Core - React-Core
@@ -2022,6 +2082,7 @@ DEPENDENCIES:
- boost (from `../../../node_modules/react-native/third-party-podspecs/boost.podspec`) - boost (from `../../../node_modules/react-native/third-party-podspecs/boost.podspec`)
- DoubleConversion (from `../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - DoubleConversion (from `../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
- EXApplication (from `../../../node_modules/expo-application/ios`) - EXApplication (from `../../../node_modules/expo-application/ios`)
- EXAV (from `../../../node_modules/expo-av/ios`)
- EXConstants (from `../../../node_modules/expo-constants/ios`) - EXConstants (from `../../../node_modules/expo-constants/ios`)
- EXImageLoader (from `../../../node_modules/expo-image-loader/ios`) - EXImageLoader (from `../../../node_modules/expo-image-loader/ios`)
- EXJSONUtils (from `../../../node_modules/expo-json-utils/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-launcher (from `../../../node_modules/expo-dev-launcher`)
- expo-dev-menu (from `../../../node_modules/expo-dev-menu`) - expo-dev-menu (from `../../../node_modules/expo-dev-menu`)
- expo-dev-menu-interface (from `../../../node_modules/expo-dev-menu-interface/ios`) - 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`) - ExpoAppleAuthentication (from `../../../node_modules/expo-apple-authentication/ios`)
- ExpoAsset (from `../../../node_modules/expo-asset/ios`) - ExpoAsset (from `../../../node_modules/expo-asset/ios`)
- ExpoBlur (from `../../../node_modules/expo-blur/ios`) - ExpoBlur (from `../../../node_modules/expo-blur/ios`)
@@ -2085,6 +2147,7 @@ DEPENDENCIES:
- React-logger (from `../../../node_modules/react-native/ReactCommon/logger`) - React-logger (from `../../../node_modules/react-native/ReactCommon/logger`)
- React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`) - React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`)
- React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - 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-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
- React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`) - React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`)
- React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - 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`) - ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`)
- "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)" - "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)"
- RNGestureHandler (from `../../../node_modules/react-native-gesture-handler`) - 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`) - RNPurchases (from `../../../node_modules/react-native-purchases`)
- RNReanimated (from `../../../node_modules/react-native-reanimated`) - RNReanimated (from `../../../node_modules/react-native-reanimated`)
- RNScreens (from `../../../node_modules/react-native-screens`) - RNScreens (from `../../../node_modules/react-native-screens`)
@@ -2123,8 +2187,15 @@ DEPENDENCIES:
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- AppAuth
- AppCheckCore
- GoogleSignIn
- GoogleUtilities
- GTMAppAuth
- GTMSessionFetcher
- libavif - libavif
- libdav1d - libdav1d
- PromisesObjC
- PurchasesHybridCommon - PurchasesHybridCommon
- RevenueCat - RevenueCat
- SDWebImage - SDWebImage
@@ -2140,6 +2211,8 @@ EXTERNAL SOURCES:
:podspec: "../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" :podspec: "../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
EXApplication: EXApplication:
:path: "../../../node_modules/expo-application/ios" :path: "../../../node_modules/expo-application/ios"
EXAV:
:path: "../../../node_modules/expo-av/ios"
EXConstants: EXConstants:
:path: "../../../node_modules/expo-constants/ios" :path: "../../../node_modules/expo-constants/ios"
EXImageLoader: EXImageLoader:
@@ -2160,6 +2233,8 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/expo-dev-menu" :path: "../../../node_modules/expo-dev-menu"
expo-dev-menu-interface: expo-dev-menu-interface:
:path: "../../../node_modules/expo-dev-menu-interface/ios" :path: "../../../node_modules/expo-dev-menu-interface/ios"
ExpoAdapterGoogleSignIn:
:path: "../../../node_modules/@react-native-google-signin/google-signin/expo/ios"
ExpoAppleAuthentication: ExpoAppleAuthentication:
:path: "../../../node_modules/expo-apple-authentication/ios" :path: "../../../node_modules/expo-apple-authentication/ios"
ExpoAsset: ExpoAsset:
@@ -2263,6 +2338,8 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/react-native/ReactCommon" :path: "../../../node_modules/react-native/ReactCommon"
React-microtasksnativemodule: React-microtasksnativemodule:
:path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
react-native-compressor:
:path: "../../../node_modules/react-native-compressor"
react-native-safe-area-context: react-native-safe-area-context:
:path: "../../../node_modules/react-native-safe-area-context" :path: "../../../node_modules/react-native-safe-area-context"
React-nativeconfig: React-nativeconfig:
@@ -2323,6 +2400,8 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/@react-native-async-storage/async-storage" :path: "../../../node_modules/@react-native-async-storage/async-storage"
RNGestureHandler: RNGestureHandler:
:path: "../../../node_modules/react-native-gesture-handler" :path: "../../../node_modules/react-native-gesture-handler"
RNGoogleSignin:
:path: "../../../node_modules/@react-native-google-signin/google-signin"
RNPurchases: RNPurchases:
:path: "../../../node_modules/react-native-purchases" :path: "../../../node_modules/react-native-purchases"
RNReanimated: RNReanimated:
@@ -2335,9 +2414,12 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/react-native/ReactCommon/yoga" :path: "../../../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS: SPEC CHECKSUMS:
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
boost: 1dca942403ed9342f98334bf4c3621f011aa7946 boost: 1dca942403ed9342f98334bf4c3621f011aa7946
DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385
EXApplication: 4c72f6017a14a65e338c5e74fca418f35141e819 EXApplication: 4c72f6017a14a65e338c5e74fca418f35141e819
EXAV: 4c41f0a6ae54a0b96597abc90e37962ab2ee0d85
EXConstants: fcfc75800824ac2d5c592b5bc74130bad17b146b EXConstants: fcfc75800824ac2d5c592b5bc74130bad17b146b
EXImageLoader: e5da974e25b13585c196b658a440720c075482d5 EXImageLoader: e5da974e25b13585c196b658a440720c075482d5
EXJSONUtils: 01fc7492b66c234e395dcffdd5f53439c5c29c93 EXJSONUtils: 01fc7492b66c234e395dcffdd5f53439c5c29c93
@@ -2348,6 +2430,7 @@ SPEC CHECKSUMS:
expo-dev-launcher: 82de1bf29e91f4794bcc65a697e7066d9a826bee expo-dev-launcher: 82de1bf29e91f4794bcc65a697e7066d9a826bee
expo-dev-menu: 3fda3eb2b3394dc92955f6ff60cb278f843641be expo-dev-menu: 3fda3eb2b3394dc92955f6ff60cb278f843641be
expo-dev-menu-interface: 00dc42302a72722fdecec3fa048de84a9133bcc4 expo-dev-menu-interface: 00dc42302a72722fdecec3fa048de84a9133bcc4
ExpoAdapterGoogleSignIn: 21ff1daeef3d21cb0b45772d8029102cc728d6b1
ExpoAppleAuthentication: 52631ed9dcb71c65712a447bbb9a5667bb8fcf0c ExpoAppleAuthentication: 52631ed9dcb71c65712a447bbb9a5667bb8fcf0c
ExpoAsset: 48386d40d53a8c1738929b3ed509bcad595b5516 ExpoAsset: 48386d40d53a8c1738929b3ed509bcad595b5516
ExpoBlur: 392c1207f71d0ecf22371621c1fbd44ba84d9742 ExpoBlur: 392c1207f71d0ecf22371621c1fbd44ba84d9742
@@ -2369,9 +2452,14 @@ SPEC CHECKSUMS:
FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45 FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45
fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6 fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6
glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
GoogleSignIn: c7f09cfbc85a1abf69187be091997c317cc33b77
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11 hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428 PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428
RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17 RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17
RCTDeprecation: ebe712bb05077934b16c6bf25228bdec34b64f83 RCTDeprecation: ebe712bb05077934b16c6bf25228bdec34b64f83
@@ -2402,6 +2490,7 @@ SPEC CHECKSUMS:
React-logger: c4052eb941cca9a097ef01b59543a656dc088559 React-logger: c4052eb941cca9a097ef01b59543a656dc088559
React-Mapbuffer: 33546a3ebefbccb8770c33a1f8a5554fa96a54de React-Mapbuffer: 33546a3ebefbccb8770c33a1f8a5554fa96a54de
React-microtasksnativemodule: 5c3d795318c22ab8df55100e50b151384a4a60b3 React-microtasksnativemodule: 5c3d795318c22ab8df55100e50b151384a4a60b3
react-native-compressor: 2c3a41768bdd367f6e121f555dff9d3eda1810e5
react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448 react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448
React-nativeconfig: 8efdb1ef1e9158c77098a93085438f7e7b463678 React-nativeconfig: 8efdb1ef1e9158c77098a93085438f7e7b463678
React-NativeModulesApple: cebca2e5320a3d66e123cade23bd90a167ffce5e React-NativeModulesApple: cebca2e5320a3d66e123cade23bd90a167ffce5e
@@ -2433,6 +2522,7 @@ SPEC CHECKSUMS:
RevenueCat: eab035bbab271faccfef5c36eaff2a1ffef14dc0 RevenueCat: eab035bbab271faccfef5c36eaff2a1ffef14dc0
RNCAsyncStorage: aa75595c1aefa18f868452091fa0c411a516ce11 RNCAsyncStorage: aa75595c1aefa18f868452091fa0c411a516ce11
RNGestureHandler: 32336458ef842088a459ccfcc10b583186de2633 RNGestureHandler: 32336458ef842088a459ccfcc10b583186de2633
RNGoogleSignin: 5782dd7dad559608a06f3c6b1d4142c395fb5308
RNPurchases: cc3333cede6c0f040545a95c38ae627d0174c058 RNPurchases: cc3333cede6c0f040545a95c38ae627d0174c058
RNReanimated: 82bf292e3c590d2d9886039f83e1baf0c1b2cdc4 RNReanimated: 82bf292e3c590d2d9886039f83e1baf0c1b2cdc4
RNScreens: 295d9c0aaeb7f680d03d7e9b476569a4959aae89 RNScreens: 295d9c0aaeb7f680d03d7e9b476569a4959aae89
@@ -174,8 +174,8 @@
LastUpgradeCheck = 1130; LastUpgradeCheck = 1130;
TargetAttributes = { TargetAttributes = {
13B07F861A680F5B00A75B9A = { 13B07F861A680F5B00A75B9A = {
DevelopmentTeam = BXVPBATDZY;
LastSwiftMigration = 1250; LastSwiftMigration = 1250;
DevelopmentTeam = "BXVPBATDZY";
ProvisioningStyle = Automatic; ProvisioningStyle = Automatic;
}; };
}; };
@@ -271,11 +271,17 @@
); );
inputPaths = ( inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh", "${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}/EXApplication/ExpoApplication_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_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}/PurchasesHybridCommon/PurchasesHybridCommon.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
@@ -291,11 +297,17 @@
); );
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputPaths = ( 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}/ExpoApplication_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.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}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_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}/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}/PurchasesHybridCommon.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
@@ -375,7 +387,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements; CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = BXVPBATDZY;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = ( GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)", "$(inherited)",
@@ -398,9 +413,6 @@
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
DEVELOPMENT_TEAM = "BXVPBATDZY";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
}; };
name = Debug; name = Debug;
}; };
@@ -411,7 +423,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements; CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = BXVPBATDZY;
INFOPLIST_FILE = Sauci/Info.plist; INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
@@ -428,9 +443,6 @@
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
DEVELOPMENT_TEAM = "BXVPBATDZY";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
}; };
name = Release; name = Release;
}; };
@@ -20,6 +20,7 @@
<key>NSPrivacyAccessedAPITypeReasons</key> <key>NSPrivacyAccessedAPITypeReasons</key>
<array> <array>
<string>CA92.1</string> <string>CA92.1</string>
<string>C56D.1</string>
</array> </array>
</dict> </dict>
<dict> <dict>
+2
View File
@@ -27,6 +27,7 @@
"expo": "~52.0.0", "expo": "~52.0.0",
"expo-apple-authentication": "~7.1.3", "expo-apple-authentication": "~7.1.3",
"expo-asset": "~11.0.5", "expo-asset": "~11.0.5",
"expo-av": "~15.0.2",
"expo-blur": "~14.0.3", "expo-blur": "~14.0.3",
"expo-build-properties": "^0.13.3", "expo-build-properties": "^0.13.3",
"expo-clipboard": "~7.0.1", "expo-clipboard": "~7.0.1",
@@ -48,6 +49,7 @@
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "0.76.9", "react-native": "0.76.9",
"react-native-compressor": "^1.13.0",
"react-native-gesture-handler": "~2.20.0", "react-native-gesture-handler": "~2.20.0",
"react-native-purchases": "^9.6.12", "react-native-purchases": "^9.6.12",
"react-native-reanimated": "~3.16.0", "react-native-reanimated": "~3.16.0",
+169 -36
View File
@@ -121,6 +121,19 @@ export function PackTeaser({
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} /> <Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
</Animated.View> </Animated.View>
<Animated.View style={[styles.content, contentStyle]}> <Animated.View style={[styles.content, contentStyle]}>
{/* Premium gradient background */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.98)', 'rgba(13, 13, 26, 1)']}
style={StyleSheet.absoluteFill}
/>
{/* Top silk highlight */}
<LinearGradient
colors={['rgba(212, 175, 55, 0.08)', 'transparent']}
style={styles.silkHighlight}
/>
{/* Premium border accent */}
<View style={styles.premiumBorderAccent} />
<ScrollView <ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
@@ -136,12 +149,35 @@ export function PackTeaser({
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
{/* Premium label */}
<Text style={styles.label}>PREMIUM COLLECTION</Text>
{/* Icon with glow */}
<View style={styles.iconWrapper}>
<View style={styles.iconContainer}> <View style={styles.iconContainer}>
<LinearGradient
colors={['rgba(212, 175, 55, 0.15)', 'rgba(184, 134, 11, 0.1)']}
style={StyleSheet.absoluteFill}
/>
<Text style={styles.packEmoji}>{packIcon || "📦"}</Text> <Text style={styles.packEmoji}>{packIcon || "📦"}</Text>
</View> </View>
{/* Crown badge */}
<View style={styles.crownBadge}>
<Ionicons name="star" size={12} color={colors.premium.gold} />
</View>
</View>
<Text style={styles.title}>{packName}</Text> <Text style={styles.title}>{packName}</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
Preview a few questions from this pack Preview a few questions from this exclusive pack
</Text> </Text>
</View> </View>
@@ -149,7 +185,7 @@ export function PackTeaser({
<View style={styles.questionsContainer}> <View style={styles.questionsContainer}>
{isLoading ? ( {isLoading ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} size="large" /> <ActivityIndicator color={colors.premium.gold} size="large" />
<Text style={styles.loadingText}> <Text style={styles.loadingText}>
Loading preview... Loading preview...
</Text> </Text>
@@ -159,12 +195,12 @@ export function PackTeaser({
<View key={question.id} style={styles.questionCard}> <View key={question.id} style={styles.questionCard}>
<View style={styles.questionHeader}> <View style={styles.questionHeader}>
<View style={styles.intensityContainer}> <View style={styles.intensityContainer}>
{[...Array(question.intensity)].map((_, i) => ( {[...Array(5)].map((_, i) => (
<Ionicons <Ionicons
key={i} key={i}
name="flame" name={i < question.intensity ? "flame" : "flame-outline"}
size={12} size={12}
color={colors.primary} color={i < question.intensity ? colors.premium.gold : 'rgba(255, 255, 255, 0.2)'}
/> />
))} ))}
</View> </View>
@@ -188,40 +224,39 @@ export function PackTeaser({
{questions.length > 0 && ( {questions.length > 0 && (
<View style={styles.moreHint}> <View style={styles.moreHint}>
<Ionicons <Ionicons
name="lock-closed" name="diamond"
size={16} size={16}
color={colors.textTertiary} color={colors.premium.gold}
/> />
<Text style={styles.moreHintText}> <Text style={styles.moreHintText}>
+ many more questions to unlock + many more exclusive questions to unlock
</Text> </Text>
</View> </View>
)} )}
{/* Unlock Button */} {/* Unlock Button */}
<View style={styles.actions}> <View style={styles.actions}>
<TouchableOpacity <Pressable
style={styles.unlockButton} style={styles.unlockButton}
onPress={onUnlock} onPress={onUnlock}
activeOpacity={0.8}
> >
<LinearGradient <LinearGradient
colors={gradients.primary as [string, string]} colors={[colors.premium.gold, colors.premium.goldDark]}
style={styles.unlockButtonGradient} style={styles.unlockButtonGradient}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
> >
<Ionicons <Ionicons
name="star" name="diamond"
size={20} size={18}
color={colors.text} color={colors.background}
style={{ marginRight: spacing.sm }} style={{ marginRight: spacing.sm }}
/> />
<Text style={styles.unlockButtonText}> <Text style={styles.unlockButtonText}>
Unlock with Pro Unlock with Pro
</Text> </Text>
</LinearGradient> </LinearGradient>
</TouchableOpacity> </Pressable>
</View> </View>
</ScrollView> </ScrollView>
</Animated.View> </Animated.View>
@@ -237,16 +272,35 @@ const styles = StyleSheet.create({
}, },
overlay: { overlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.8)", backgroundColor: "rgba(0, 0, 0, 0.85)",
}, },
content: { content: {
backgroundColor: colors.backgroundLight, backgroundColor: colors.background,
borderTopLeftRadius: radius.xxl, borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl, borderTopRightRadius: radius.xxl,
maxHeight: "85%", maxHeight: "85%",
borderWidth: 1, borderWidth: 1,
borderBottomWidth: 0, 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: { scrollContent: {
padding: spacing.lg, padding: spacing.lg,
@@ -260,36 +314,98 @@ const styles = StyleSheet.create({
width: 40, width: 40,
height: 40, height: 40,
borderRadius: 20, borderRadius: 20,
backgroundColor: colors.glass.background, backgroundColor: 'rgba(255, 255, 255, 0.08)',
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
zIndex: 10, zIndex: 10,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
}, },
header: { header: {
alignItems: "center", alignItems: "center",
marginBottom: spacing.xl, marginBottom: spacing.xl,
}, },
iconContainer: { label: {
width: 72, ...typography.caption1,
height: 72, fontWeight: '600',
borderRadius: 36, letterSpacing: 3,
backgroundColor: colors.glass.background, color: colors.premium.gold,
justifyContent: "center", textAlign: 'center',
alignItems: "center",
marginBottom: spacing.md, 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: { packEmoji: {
fontSize: 36, fontSize: 40,
}, },
title: { title: {
...typography.title1, ...typography.title1,
color: colors.text, color: colors.text,
marginBottom: spacing.xs, 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: { subtitle: {
...typography.body, ...typography.body,
color: colors.textSecondary, color: colors.textSecondary,
textAlign: "center", textAlign: "center",
lineHeight: 24,
}, },
questionsContainer: { questionsContainer: {
marginBottom: spacing.lg, marginBottom: spacing.lg,
@@ -305,10 +421,10 @@ const styles = StyleSheet.create({
marginTop: spacing.md, marginTop: spacing.md,
}, },
questionCard: { questionCard: {
backgroundColor: colors.glass.background, backgroundColor: 'rgba(22, 33, 62, 0.4)',
borderRadius: radius.lg, borderRadius: radius.lg,
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.border, borderColor: 'rgba(212, 175, 55, 0.15)',
padding: spacing.md, padding: spacing.md,
}, },
questionHeader: { questionHeader: {
@@ -319,20 +435,23 @@ const styles = StyleSheet.create({
}, },
intensityContainer: { intensityContainer: {
flexDirection: "row", flexDirection: "row",
backgroundColor: colors.primaryLight, backgroundColor: 'rgba(212, 175, 55, 0.1)',
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
paddingVertical: 4, paddingVertical: 4,
borderRadius: radius.full, borderRadius: radius.full,
gap: 1, gap: 2,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)',
}, },
questionNumber: { questionNumber: {
...typography.caption2, ...typography.caption2,
color: colors.textTertiary, color: colors.premium.champagne,
opacity: 0.7,
}, },
questionText: { questionText: {
...typography.body, ...typography.body,
color: colors.text, color: colors.text,
lineHeight: 22, lineHeight: 24,
}, },
emptyContainer: { emptyContainer: {
alignItems: "center", alignItems: "center",
@@ -347,11 +466,19 @@ const styles = StyleSheet.create({
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
marginBottom: spacing.lg, 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: { moreHintText: {
...typography.caption1, ...typography.caption1,
color: colors.textTertiary, color: colors.premium.champagne,
fontWeight: '500',
}, },
actions: { actions: {
marginBottom: spacing.md, marginBottom: spacing.md,
@@ -359,6 +486,11 @@ const styles = StyleSheet.create({
unlockButton: { unlockButton: {
borderRadius: radius.lg, borderRadius: radius.lg,
overflow: "hidden", overflow: "hidden",
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
}, },
unlockButtonGradient: { unlockButtonGradient: {
flexDirection: "row", flexDirection: "row",
@@ -368,7 +500,8 @@ const styles = StyleSheet.create({
}, },
unlockButtonText: { unlockButtonText: {
...typography.headline, ...typography.headline,
color: colors.text, color: colors.background,
fontWeight: '700',
}, },
}); });
+230 -54
View File
@@ -8,6 +8,7 @@ import {
ActivityIndicator, ActivityIndicator,
ScrollView, ScrollView,
Platform, Platform,
Pressable,
} from "react-native"; } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
@@ -24,9 +25,9 @@ interface PaywallProps {
const FEATURES = [ const FEATURES = [
{ {
icon: "heart" as const, icon: "diamond" as const,
title: "Unlock All Packs", title: "Unlock All Packs",
description: "Access every question pack", description: "Access every premium collection",
}, },
{ {
icon: "sparkles" as const, icon: "sparkles" as const,
@@ -34,7 +35,7 @@ const FEATURES = [
description: "New packs added regularly", description: "New packs added regularly",
}, },
{ {
icon: "people" as const, icon: "heart" as const,
title: "Share with Partner", title: "Share with Partner",
description: "Both of you get Pro access", description: "Both of you get Pro access",
}, },
@@ -125,6 +126,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const savings = calculateSavings(); const savings = calculateSavings();
return ( return (
<Modal <Modal
visible={visible} visible={visible}
@@ -134,6 +136,19 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
> >
<View style={styles.overlay}> <View style={styles.overlay}>
<View style={styles.content}> <View style={styles.content}>
{/* Premium gradient background */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.98)', 'rgba(13, 13, 26, 1)']}
style={StyleSheet.absoluteFill}
/>
{/* Top silk highlight */}
<LinearGradient
colors={['rgba(212, 175, 55, 0.08)', 'transparent']}
style={styles.silkHighlight}
/>
{/* Premium border accent */}
<View style={styles.premiumBorderAccent} />
<ScrollView <ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
@@ -149,15 +164,29 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
{/* Premium label */}
<Text style={styles.label}>EXCLUSIVE ACCESS</Text>
{/* Icon with glow */}
<View style={styles.iconContainer}>
<LinearGradient <LinearGradient
colors={gradients.primary as [string, string]} colors={[colors.premium.gold, colors.premium.goldDark]}
style={styles.iconContainer} style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
> />
<Ionicons name="star" size={32} color={colors.text} /> <Ionicons name="diamond" size={32} color={colors.background} />
</LinearGradient> </View>
<Text style={styles.title}>Upgrade to Pro</Text>
<Text style={styles.title}>Sauci Pro</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
Unlock all premium content for you and your partner Unlock all premium content for you and your partner
</Text> </Text>
@@ -165,13 +194,13 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
{/* Features */} {/* Features */}
<View style={styles.features}> <View style={styles.features}>
{FEATURES.map((feature) => ( {FEATURES.map((feature, index) => (
<View key={feature.title} style={styles.featureRow}> <View key={feature.title} style={styles.featureRow}>
<View style={styles.featureIcon}> <View style={styles.featureIcon}>
<Ionicons <Ionicons
name={feature.icon} name={feature.icon}
size={20} size={18}
color={colors.primary} color={colors.premium.gold}
/> />
</View> </View>
<View style={styles.featureText}> <View style={styles.featureText}>
@@ -180,6 +209,11 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
{feature.description} {feature.description}
</Text> </Text>
</View> </View>
<Ionicons
name="checkmark-circle"
size={20}
color={colors.premium.gold}
/>
</View> </View>
))} ))}
</View> </View>
@@ -188,9 +222,9 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
<View style={styles.packages}> <View style={styles.packages}>
{isLoadingOfferings ? ( {isLoadingOfferings ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} size="large" /> <ActivityIndicator color={colors.premium.gold} size="large" />
<Text style={styles.loadingText}> <Text style={styles.loadingText}>
Loading subscription options... Loading options...
</Text> </Text>
</View> </View>
) : offerings?.availablePackages ? ( ) : offerings?.availablePackages ? (
@@ -200,17 +234,30 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const isAnnual = pkg.packageType === "ANNUAL"; const isAnnual = pkg.packageType === "ANNUAL";
return ( return (
<TouchableOpacity <Pressable
key={pkg.identifier} key={pkg.identifier}
onPress={() => setSelectedPackage(pkg)} onPress={() => setSelectedPackage(pkg)}
activeOpacity={0.7}
style={[ style={[
styles.packageCard, styles.packageCard,
isSelected && styles.packageCardSelected, isSelected && styles.packageCardSelected,
isAnnual && styles.packageCardAnnual,
]} ]}
> >
{isAnnual && (
<LinearGradient
colors={['rgba(212, 175, 55, 0.1)', 'rgba(184, 134, 11, 0.05)']}
style={StyleSheet.absoluteFill}
/>
)}
{isAnnual && savings && savings > 0 && ( {isAnnual && savings && savings > 0 && (
<View style={styles.savingsBadge}> <View style={styles.savingsBadge}>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
<Ionicons name="star" size={10} color={colors.background} />
<Text style={styles.savingsText}> <Text style={styles.savingsText}>
SAVE {savings}% SAVE {savings}%
</Text> </Text>
@@ -226,15 +273,28 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
{isSelected && <View style={styles.radioInner} />} {isSelected && <View style={styles.radioInner} />}
</View> </View>
<View style={styles.packageInfo}> <View style={styles.packageInfo}>
<Text style={styles.packageTitle}> <View style={styles.packageTitleRow}>
<Text style={[
styles.packageTitle,
isAnnual && styles.packageTitleAnnual
]}>
{isAnnual ? "Annual" : "Monthly"} {isAnnual ? "Annual" : "Monthly"}
</Text> </Text>
<Text style={styles.packagePrice}> {isAnnual && (
<View style={styles.bestValueBadge}>
<Text style={styles.bestValueText}>BEST VALUE</Text>
</View>
)}
</View>
<Text style={[
styles.packagePrice,
isAnnual && styles.packagePriceAnnual
]}>
{formatPrice(pkg)} {formatPrice(pkg)}
</Text> </Text>
</View> </View>
</View> </View>
</TouchableOpacity> </Pressable>
); );
}) })
) : ( ) : (
@@ -266,7 +326,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
{/* Purchase Button */} {/* Purchase Button */}
<View style={styles.actions}> <View style={styles.actions}>
<TouchableOpacity <Pressable
style={[ style={[
styles.purchaseButton, styles.purchaseButton,
(!selectedPackage || isPurchasing) && (!selectedPackage || isPurchasing) &&
@@ -274,21 +334,23 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
]} ]}
onPress={handlePurchase} onPress={handlePurchase}
disabled={!selectedPackage || isPurchasing} disabled={!selectedPackage || isPurchasing}
activeOpacity={0.8}
> >
{isPurchasing ? ( {isPurchasing ? (
<ActivityIndicator size="small" color={colors.text} /> <View style={styles.purchaseButtonLoading}>
<ActivityIndicator size="small" color={colors.background} />
</View>
) : ( ) : (
<LinearGradient <LinearGradient
colors={gradients.primary as [string, string]} colors={[colors.premium.gold, colors.premium.goldDark]}
style={styles.purchaseButtonGradient} style={styles.purchaseButtonGradient}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
> >
<Text style={styles.purchaseButtonText}>Subscribe Now</Text> <Ionicons name="diamond" size={18} color={colors.background} style={{ marginRight: spacing.sm }} />
<Text style={styles.purchaseButtonText}>Unlock Pro</Text>
</LinearGradient> </LinearGradient>
)} )}
</TouchableOpacity> </Pressable>
<TouchableOpacity <TouchableOpacity
style={styles.restoreButton} style={styles.restoreButton}
@@ -317,17 +379,36 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
overlay: { overlay: {
flex: 1, flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.8)", backgroundColor: "rgba(0, 0, 0, 0.85)",
justifyContent: "flex-end", justifyContent: "flex-end",
}, },
content: { content: {
backgroundColor: colors.backgroundLight, backgroundColor: colors.background,
borderTopLeftRadius: radius.xxl, borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl, borderTopRightRadius: radius.xxl,
maxHeight: "90%", maxHeight: "90%",
borderWidth: 1, borderWidth: 1,
borderBottomWidth: 0, 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: { scrollContent: {
padding: spacing.lg, padding: spacing.lg,
@@ -341,49 +422,96 @@ const styles = StyleSheet.create({
width: 40, width: 40,
height: 40, height: 40,
borderRadius: 20, borderRadius: 20,
backgroundColor: colors.glass.background, backgroundColor: 'rgba(255, 255, 255, 0.08)',
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
zIndex: 10, zIndex: 10,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
}, },
header: { header: {
alignItems: "center", alignItems: "center",
marginBottom: spacing.xl, marginBottom: spacing.xl,
}, },
label: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: colors.premium.gold,
textAlign: 'center',
marginBottom: spacing.md,
},
iconContainer: { iconContainer: {
width: 72, width: 72,
height: 72, height: 72,
borderRadius: 36, borderRadius: 36,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
overflow: 'hidden',
marginBottom: spacing.md, marginBottom: spacing.md,
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.6,
shadowRadius: 20,
elevation: 12,
}, },
title: { title: {
...typography.title1, ...typography.title1,
color: colors.text, color: colors.text,
marginBottom: spacing.xs, 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: { subtitle: {
...typography.body, ...typography.body,
color: colors.textSecondary, color: colors.textSecondary,
textAlign: "center", textAlign: "center",
lineHeight: 24,
}, },
features: { features: {
marginBottom: spacing.xl, marginBottom: spacing.xl,
backgroundColor: 'rgba(212, 175, 55, 0.05)',
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.1)',
}, },
featureRow: { featureRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
marginBottom: spacing.md, paddingVertical: spacing.sm,
}, },
featureIcon: { featureIcon: {
width: 44, width: 40,
height: 44, height: 40,
borderRadius: 22, borderRadius: 20,
backgroundColor: colors.primaryLight, backgroundColor: 'rgba(212, 175, 55, 0.1)',
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
marginRight: spacing.md, marginRight: spacing.md,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
}, },
featureText: { featureText: {
flex: 1, flex: 1,
@@ -411,30 +539,38 @@ const styles = StyleSheet.create({
}, },
packageCard: { packageCard: {
position: "relative", position: "relative",
backgroundColor: colors.glass.background, backgroundColor: 'rgba(22, 33, 62, 0.4)',
borderRadius: radius.lg, borderRadius: radius.lg,
borderWidth: 2, borderWidth: 1,
borderColor: colors.glass.border, borderColor: 'rgba(255, 255, 255, 0.08)',
padding: spacing.md, padding: spacing.md,
overflow: "visible", overflow: "hidden",
}, },
packageCardSelected: { packageCardSelected: {
borderColor: colors.primary, borderColor: colors.premium.gold,
backgroundColor: colors.primaryLight, borderWidth: 2,
},
packageCardAnnual: {
borderColor: 'rgba(212, 175, 55, 0.3)',
}, },
savingsBadge: { savingsBadge: {
position: "absolute", position: "absolute",
top: -10, top: -1,
right: spacing.md, right: spacing.md,
backgroundColor: colors.primary, flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
paddingVertical: 4, paddingVertical: 4,
borderRadius: radius.xs, borderBottomLeftRadius: radius.sm,
borderBottomRightRadius: radius.sm,
overflow: 'hidden',
}, },
savingsText: { savingsText: {
...typography.caption2, ...typography.caption2,
color: colors.text, color: colors.background,
fontWeight: "700", fontWeight: "800",
letterSpacing: 0.5,
}, },
packageContent: { packageContent: {
flexDirection: "row", flexDirection: "row",
@@ -445,30 +581,55 @@ const styles = StyleSheet.create({
height: 24, height: 24,
borderRadius: 12, borderRadius: 12,
borderWidth: 2, borderWidth: 2,
borderColor: colors.textSecondary, borderColor: 'rgba(255, 255, 255, 0.3)',
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
marginRight: spacing.md, marginRight: spacing.md,
}, },
radioOuterSelected: { radioOuterSelected: {
borderColor: colors.primary, borderColor: colors.premium.gold,
}, },
radioInner: { radioInner: {
width: 12, width: 12,
height: 12, height: 12,
borderRadius: 6, borderRadius: 6,
backgroundColor: colors.primary, backgroundColor: colors.premium.gold,
}, },
packageInfo: { packageInfo: {
flex: 1, flex: 1,
}, },
packageTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
packageTitle: { packageTitle: {
...typography.headline, ...typography.headline,
color: colors.text, color: colors.text,
}, },
packageTitleAnnual: {
color: colors.premium.champagne,
},
bestValueBadge: {
backgroundColor: 'rgba(212, 175, 55, 0.2)',
paddingHorizontal: spacing.xs,
paddingVertical: 2,
borderRadius: radius.xs,
},
bestValueText: {
...typography.caption2,
color: colors.premium.gold,
fontWeight: '700',
letterSpacing: 0.5,
},
packagePrice: { packagePrice: {
...typography.subhead, ...typography.subhead,
color: colors.textSecondary, color: colors.textSecondary,
marginTop: 2,
},
packagePriceAnnual: {
color: colors.premium.champagne,
opacity: 0.8,
}, },
errorContainer: { errorContainer: {
alignItems: "center", alignItems: "center",
@@ -481,16 +642,16 @@ const styles = StyleSheet.create({
marginBottom: spacing.md, marginBottom: spacing.md,
}, },
retryButton: { retryButton: {
backgroundColor: colors.glass.background, backgroundColor: 'rgba(212, 175, 55, 0.1)',
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
borderRadius: radius.md, borderRadius: radius.md,
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.border, borderColor: 'rgba(212, 175, 55, 0.2)',
}, },
retryButtonText: { retryButtonText: {
...typography.subhead, ...typography.subhead,
color: colors.primary, color: colors.premium.gold,
fontWeight: "600", fontWeight: "600",
}, },
errorBanner: { errorBanner: {
@@ -510,17 +671,31 @@ const styles = StyleSheet.create({
purchaseButton: { purchaseButton: {
borderRadius: radius.lg, borderRadius: radius.lg,
overflow: "hidden", overflow: "hidden",
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
}, },
purchaseButtonDisabled: { purchaseButtonDisabled: {
opacity: 0.5, opacity: 0.5,
shadowOpacity: 0,
}, },
purchaseButtonGradient: { purchaseButtonLoading: {
paddingVertical: spacing.md, paddingVertical: spacing.md,
alignItems: "center", alignItems: "center",
backgroundColor: colors.premium.gold,
},
purchaseButtonGradient: {
flexDirection: 'row',
paddingVertical: spacing.md,
alignItems: "center",
justifyContent: 'center',
}, },
purchaseButtonText: { purchaseButtonText: {
...typography.headline, ...typography.headline,
color: colors.text, color: colors.background,
fontWeight: '700',
}, },
restoreButton: { restoreButton: {
alignItems: "center", alignItems: "center",
@@ -529,7 +704,8 @@ const styles = StyleSheet.create({
}, },
restoreText: { restoreText: {
...typography.subhead, ...typography.subhead,
color: colors.primary, color: colors.premium.gold,
opacity: 0.8,
}, },
legal: { legal: {
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
@@ -0,0 +1,513 @@
/**
* SwipeCardPremium - PoC of premium/boutique styling for the swipe experience
*
* Inspired by the "Coming Soon" Ann Summers aesthetic
*/
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, useWindowDimensions, Pressable } from "react-native";
import { Gesture, GestureDetector, TouchableOpacity } from "react-native-gesture-handler";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
runOnJS,
interpolate,
Extrapolate,
withTiming,
interpolateColor,
Easing,
FadeIn,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, featureColors, radius, shadows, blur, typography, spacing, animations } from "../theme";
// Use feature colors for consistency
const { accent: ACCENT } = featureColors.dares;
const { accent: ROSE } = featureColors.quiz;
// RGBA helpers for custom opacity levels (derived from theme colors)
// Gold: #D4AF37 = rgb(212, 175, 55)
// Rose: #E8A4AE = rgb(232, 164, 174)
const rgba = {
gold: (opacity: number) => `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 (
<View style={[styles.cardWrapper, { width: cardWidth }]}>
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.cardOuter, animatedStyle, cardShadowStyle, { shadowColor: ACCENT }]}>
{/* Premium dark background */}
<View style={styles.cardBackground}>
{/* Subtle gradient overlay */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
style={StyleSheet.absoluteFill}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[rgba.gold(0.08), 'transparent']}
style={styles.silkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Swipe Overlays - Premium style */}
<Animated.View style={[styles.overlay, styles.overlayYes, overlayStyle("right")]} pointerEvents="none">
<View style={styles.overlayBadgePremium}>
<Text style={styles.overlayTextPremium}>YES</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, styles.overlayNo, overlayStyle("left")]} pointerEvents="none">
<View style={[styles.overlayBadgePremium, styles.overlayBadgeNo]}>
<Text style={[styles.overlayTextPremium, styles.overlayTextNo]}>NO</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, styles.overlayMaybe, overlayStyle("up")]} pointerEvents="none">
<View style={[styles.overlayBadgePremium, styles.overlayBadgeMaybe]}>
<Text style={[styles.overlayTextPremium, styles.overlayTextMaybe]}>MAYBE</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, styles.overlaySkip, overlayStyle("down")]} pointerEvents="none">
<View style={[styles.overlayBadgePremium, styles.overlayBadgeSkip]}>
<Text style={[styles.overlayTextPremium, styles.overlayTextSkip]}>SKIP</Text>
</View>
</Animated.View>
{/* Content */}
<View style={styles.content}>
{/* Intensity badge - more prominent */}
<View style={styles.intensityBadge}>
<Text style={styles.intensityLabel}>INTENSITY</Text>
<View style={styles.intensityRow}>
{[...Array(5)].map((_, i) => (
<Ionicons
key={i}
name={i < question.intensity ? "flame" : "flame-outline"}
size={18}
color={i < question.intensity ? ACCENT : 'rgba(255, 255, 255, 0.2)'}
/>
))}
</View>
</View>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
{/* Question text */}
<Text style={styles.questionText}>
{question.text}
</Text>
{/* Bottom separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
</View>
{/* Premium Action Buttons */}
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<PremiumButton
onPress={() => handleButtonPress("left")}
icon="close"
variant="no"
/>
<PremiumButton
onPress={() => handleButtonPress("up")}
icon="help"
variant="maybe"
small
/>
<PremiumButton
onPress={() => handleButtonPress("right")}
icon="heart"
variant="yes"
/>
</View>
</View>
</View>
{/* Premium border */}
<View style={styles.premiumBorder} pointerEvents="none" />
</Animated.View>
</GestureDetector>
</View>
);
}
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 (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
>
<Animated.View style={[
styles.premiumButton,
small && styles.premiumButtonSmall,
{ backgroundColor: c.bg, borderColor: c.border },
buttonAnimatedStyle,
]}>
<Ionicons
name={icon as any}
size={small ? 18 : 24}
color={c.icon}
/>
</Animated.View>
</TouchableOpacity>
);
}
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,
},
});
+139
View File
@@ -1,4 +1,6 @@
import { supabase } from './supabase'; import { supabase } from './supabase';
import * as FileSystem from 'expo-file-system';
import { Platform } from 'react-native';
// Cache for signed URLs with expiration tracking // Cache for signed URLs with expiration tracking
interface CachedUrl { interface CachedUrl {
@@ -8,6 +10,12 @@ interface CachedUrl {
const signedUrlCache = new Map<string, CachedUrl>(); const signedUrlCache = new Map<string, CachedUrl>();
// In-memory tracking of videos currently being cached to prevent duplicate downloads
const videoCacheInProgress = new Set<string>();
// Video cache directory
const VIDEO_CACHE_DIR = `${FileSystem.cacheDirectory}video-cache/`;
// Buffer time before expiration to refresh URL (5 minutes) // Buffer time before expiration to refresh URL (5 minutes)
const EXPIRATION_BUFFER_MS = 5 * 60 * 1000; const EXPIRATION_BUFFER_MS = 5 * 60 * 1000;
// Default signed URL expiry (1 hour) // Default signed URL expiry (1 hour)
@@ -93,3 +101,134 @@ export async function prefetchSignedUrls(
mediaPaths.map(path => getCachedSignedUrl(getStoragePath(path), bucket)) mediaPaths.map(path => getCachedSignedUrl(getStoragePath(path), bucket))
); );
} }
/**
* Ensure the video cache directory exists.
*/
async function ensureVideoCacheDir(): Promise<void> {
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<string | null> {
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<string | null> {
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<void> {
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<number> {
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;
}
}
+12 -2
View File
@@ -211,8 +211,18 @@ export const useMatchStore = create<MatchState>((set, get) => ({
unreadCount: unreadCounts[match.id] || 0 unreadCount: unreadCounts[match.id] || 0
})); }));
const newCount = data.filter((m) => m.is_new).length; // Sort: unread messages first, then by most recent
set({ matches: data, newMatchesCount: newCount }); 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) => { markAsSeen: async (matchId) => {
+67 -2
View File
@@ -42,13 +42,28 @@ export const colors = {
// Premium/Luxury // Premium/Luxury
premium: { premium: {
gold: '#D4AF37', gold: '#D4AF37',
goldDark: '#B8860B',
goldLight: 'rgba(212, 175, 55, 0.15)', goldLight: 'rgba(212, 175, 55, 0.15)',
goldGlow: 'rgba(212, 175, 55, 0.3)', goldGlow: 'rgba(212, 175, 55, 0.3)',
champagne: '#F7E7CE', champagne: '#F7E7CE',
champagneLight: 'rgba(247, 231, 206, 0.1)', 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)', 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 = { export const gradients = {
@@ -64,7 +79,7 @@ export const gradients = {
error: ['#E74C3C', '#c0392b'], error: ['#E74C3C', '#c0392b'],
// Premium/Boutique gradients // Premium/Boutique gradients
premiumGold: ['#D4AF37', '#B8860B'], premiumGold: ['#D4AF37', '#B8860B'],
premiumRose: ['#B76E79', '#8B4557'], premiumRose: ['#E8A4AE', '#D4919B'],
silkLight: ['rgba(255, 255, 255, 0.08)', 'rgba(255, 255, 255, 0.02)'], 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)'], laceTint: ['rgba(233, 69, 96, 0.15)', 'rgba(155, 89, 182, 0.15)'],
// Boutique pack card backgrounds // Boutique pack card backgrounds
@@ -76,6 +91,55 @@ export const gradients = {
boutiqueAmethyst: ['rgba(142, 68, 173, 0.4)', 'rgba(44, 62, 80, 0.4)'], 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 = { export const spacing = {
xs: 4, xs: 4,
sm: 8, sm: 8,
@@ -222,6 +286,7 @@ export const animations = {
export const theme = { export const theme = {
colors, colors,
gradients, gradients,
featureColors,
spacing, spacing,
radius, radius,
typography, typography,
+638 -100
View File
@@ -14,33 +14,152 @@ export type Database = {
} }
public: { public: {
Tables: { Tables: {
categories: { admin_users: {
Row: { Row: {
id: string
name: string
description: string | null
icon: string | null
sort_order: number | null
created_at: string | null 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: { Insert: {
id?: string
name: string
description?: string | null
icon?: string | null
sort_order?: number | null
created_at?: string | null 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: { Update: {
id?: string
name?: string
description?: string | null
icon?: string | null
sort_order?: number | null
created_at?: string | null 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: [] 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: { couple_packs: {
Row: { Row: {
couple_id: string couple_id: string
@@ -95,6 +214,87 @@ export type Database = {
} }
Relationships: [] 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: { matches: {
Row: { Row: {
couple_id: string couple_id: string
@@ -144,7 +344,10 @@ export type Database = {
delivered_at: string | null delivered_at: string | null
id: string id: string
match_id: string match_id: string
media_expired: boolean | null
media_expires_at: string | null
media_path: string | null media_path: string | null
media_type: string | null
media_viewed_at: string | null media_viewed_at: string | null
read_at: string | null read_at: string | null
user_id: string user_id: string
@@ -155,7 +358,10 @@ export type Database = {
delivered_at?: string | null delivered_at?: string | null
id?: string id?: string
match_id: string match_id: string
media_expired?: boolean | null
media_expires_at?: string | null
media_path?: string | null media_path?: string | null
media_type?: string | null
media_viewed_at?: string | null media_viewed_at?: string | null
read_at?: string | null read_at?: string | null
user_id: string user_id: string
@@ -166,7 +372,10 @@ export type Database = {
delivered_at?: string | null delivered_at?: string | null
id?: string id?: string
match_id?: string match_id?: string
media_expired?: boolean | null
media_expires_at?: string | null
media_path?: string | null media_path?: string | null
media_type?: string | null
media_viewed_at?: string | null media_viewed_at?: string | null
read_at?: string | null read_at?: string | null
user_id?: string user_id?: string
@@ -181,39 +390,81 @@ export type Database = {
}, },
] ]
} }
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: { profiles: {
Row: { Row: {
avatar_url: string | null avatar_url: string | null
couple_id: string | null couple_id: string | null
created_at: string | null created_at: string | null
email: string | null email: string | null
gender: string | null
id: string id: string
is_premium: boolean | null is_premium: boolean | null
name: string | null name: string | null
onboarding_completed: boolean | null
push_token: string | null push_token: string | null
show_explicit_content: boolean | null
updated_at: string | null updated_at: string | null
usage_reason: string | null
} }
Insert: { Insert: {
avatar_url?: string | null avatar_url?: string | null
couple_id?: string | null couple_id?: string | null
created_at?: string | null created_at?: string | null
email?: string | null email?: string | null
gender?: string | null
id: string id: string
is_premium?: boolean | null is_premium?: boolean | null
name?: string | null name?: string | null
onboarding_completed?: boolean | null
push_token?: string | null push_token?: string | null
show_explicit_content?: boolean | null
updated_at?: string | null updated_at?: string | null
usage_reason?: string | null
} }
Update: { Update: {
avatar_url?: string | null avatar_url?: string | null
couple_id?: string | null couple_id?: string | null
created_at?: string | null created_at?: string | null
email?: string | null email?: string | null
gender?: string | null
id?: string id?: string
is_premium?: boolean | null is_premium?: boolean | null
name?: string | null name?: string | null
onboarding_completed?: boolean | null
push_token?: string | null push_token?: string | null
show_explicit_content?: boolean | null
updated_at?: string | null updated_at?: string | null
usage_reason?: string | null
} }
Relationships: [ Relationships: [
{ {
@@ -227,37 +478,40 @@ export type Database = {
} }
question_packs: { question_packs: {
Row: { 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 category_id: string | null
created_at: 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: { 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 category_id?: string | null
created_at?: 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: { 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 category_id?: string | null
created_at?: 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: [ Relationships: [
{ {
@@ -266,33 +520,39 @@ export type Database = {
isOneToOne: false isOneToOne: false
referencedRelation: "categories" referencedRelation: "categories"
referencedColumns: ["id"] referencedColumns: ["id"]
} },
] ]
} }
questions: { questions: {
Row: { Row: {
allowed_couple_genders: string[] | null
created_at: string | null created_at: string | null
id: string id: string
intensity: number | null intensity: number | null
pack_id: string pack_id: string
text: string
partner_text: string | null partner_text: string | null
target_user_genders: string[] | null
text: string
} }
Insert: { Insert: {
allowed_couple_genders?: string[] | null
created_at?: string | null created_at?: string | null
id?: string id?: string
intensity?: number | null intensity?: number | null
pack_id: string pack_id: string
text: string
partner_text?: string | null partner_text?: string | null
target_user_genders?: string[] | null
text: string
} }
Update: { Update: {
allowed_couple_genders?: string[] | null
created_at?: string | null created_at?: string | null
id?: string id?: string
intensity?: number | null intensity?: number | null
pack_id?: string pack_id?: string
text?: string
partner_text?: string | null partner_text?: string | null
target_user_genders?: string[] | null
text?: string
} }
Relationships: [ Relationships: [
{ {
@@ -304,6 +564,45 @@ export type Database = {
}, },
] ]
} }
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: { responses: {
Row: { Row: {
answer: Database["public"]["Enums"]["answer_type"] answer: Database["public"]["Enums"]["answer_type"]
@@ -344,31 +643,245 @@ export type Database = {
referencedRelation: "questions" referencedRelation: "questions"
referencedColumns: ["id"] 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: []
}
} }
Views: { Views: {
[_ in never]: never [_ in never]: never
} }
Functions: { Functions: {
get_recommended_questions: { cleanup_expired_videos: {
Args: { Args: Record<PropertyKey, never>
target_pack_id?: string 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<PropertyKey, never>
Returns: {
is_blocked: boolean
threshold: number
unanswered_by_partner: number
}[]
}
get_auth_user_couple_id: { Args: Record<PropertyKey, never>; Returns: string }
get_pack_teaser_questions: {
Args: { target_pack_id: string }
Returns: { Returns: {
id: string id: string
intensity: number
text: string text: string
partner_text: string | null }[]
}
get_profiles_with_auth_info: {
Args: Record<PropertyKey, never>
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 is_two_part: boolean
pack_id: string pack_id: string
intensity: number
partner_answered: boolean 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<PropertyKey, never>
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: { Enums: {
admin_role: "pack_creator" | "super_admin"
answer_type: "yes" | "no" | "maybe" 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" match_type: "yes_yes" | "yes_maybe" | "maybe_maybe"
subscription_status:
| "active"
| "cancelled"
| "expired"
| "billing_issue"
| "paused"
} }
CompositeTypes: { CompositeTypes: {
[_ in never]: never [_ in never]: never
@@ -376,33 +889,33 @@ export type Database = {
} }
} }
type PublicSchema = Database[Extract<keyof Database, "public">] type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
// Helper type to get schema keys excluding internal keys type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
type SchemaName = Exclude<keyof Database, "__InternalSupabase">
// Helper to safely get schema
type GetSchema<S extends SchemaName> = Database[S]
export type Tables< export type Tables<
PublicTableNameOrOptions extends DefaultSchemaTableNameOrOptions extends
| keyof (PublicSchema["Tables"] & PublicSchema["Views"]) | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: SchemaName }, | { schema: keyof DatabaseWithoutInternals },
TableName extends PublicTableNameOrOptions extends { schema: SchemaName } TableName extends DefaultSchemaTableNameOrOptions extends {
? keyof (GetSchema<PublicTableNameOrOptions["schema"]>["Tables"] & schema: keyof DatabaseWithoutInternals
GetSchema<PublicTableNameOrOptions["schema"]>["Views"]) }
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never, : never = never,
> = PublicTableNameOrOptions extends { schema: SchemaName } > = DefaultSchemaTableNameOrOptions extends {
? (GetSchema<PublicTableNameOrOptions["schema"]>["Tables"] & schema: keyof DatabaseWithoutInternals
GetSchema<PublicTableNameOrOptions["schema"]>["Views"])[TableName] extends { }
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R Row: infer R
} }
? R ? R
: never : never
: PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] & : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
PublicSchema["Views"]) DefaultSchema["Views"])
? (PublicSchema["Tables"] & ? (DefaultSchema["Tables"] &
PublicSchema["Views"])[PublicTableNameOrOptions] extends { DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R Row: infer R
} }
? R ? R
@@ -410,20 +923,24 @@ export type Tables<
: never : never
export type TablesInsert< export type TablesInsert<
PublicTableNameOrOptions extends DefaultSchemaTableNameOrOptions extends
| keyof PublicSchema["Tables"] | keyof DefaultSchema["Tables"]
| { schema: SchemaName }, | { schema: keyof DatabaseWithoutInternals },
TableName extends PublicTableNameOrOptions extends { schema: SchemaName } TableName extends DefaultSchemaTableNameOrOptions extends {
? keyof GetSchema<PublicTableNameOrOptions["schema"]>["Tables"] schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never, : never = never,
> = PublicTableNameOrOptions extends { schema: SchemaName } > = DefaultSchemaTableNameOrOptions extends {
? GetSchema<PublicTableNameOrOptions["schema"]>["Tables"][TableName] extends { schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I Insert: infer I
} }
? I ? I
: never : never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"] : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends { ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I Insert: infer I
} }
? I ? I
@@ -431,20 +948,24 @@ export type TablesInsert<
: never : never
export type TablesUpdate< export type TablesUpdate<
PublicTableNameOrOptions extends DefaultSchemaTableNameOrOptions extends
| keyof PublicSchema["Tables"] | keyof DefaultSchema["Tables"]
| { schema: SchemaName }, | { schema: keyof DatabaseWithoutInternals },
TableName extends PublicTableNameOrOptions extends { schema: SchemaName } TableName extends DefaultSchemaTableNameOrOptions extends {
? keyof GetSchema<PublicTableNameOrOptions["schema"]>["Tables"] schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never, : never = never,
> = PublicTableNameOrOptions extends { schema: SchemaName } > = DefaultSchemaTableNameOrOptions extends {
? GetSchema<PublicTableNameOrOptions["schema"]>["Tables"][TableName] extends { schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U Update: infer U
} }
? U ? U
: never : never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"] : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends { ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U Update: infer U
} }
? U ? U
@@ -452,38 +973,55 @@ export type TablesUpdate<
: never : never
export type Enums< export type Enums<
PublicEnumNameOrOptions extends DefaultSchemaEnumNameOrOptions extends
| keyof PublicSchema["Enums"] | keyof DefaultSchema["Enums"]
| { schema: SchemaName }, | { schema: keyof DatabaseWithoutInternals },
EnumName extends PublicEnumNameOrOptions extends { schema: SchemaName } EnumName extends DefaultSchemaEnumNameOrOptions extends {
? keyof GetSchema<PublicEnumNameOrOptions["schema"]>["Enums"] schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never, : never = never,
> = PublicEnumNameOrOptions extends { schema: SchemaName } > = DefaultSchemaEnumNameOrOptions extends {
? GetSchema<PublicEnumNameOrOptions["schema"]>["Enums"][EnumName] schema: keyof DatabaseWithoutInternals
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] }
? PublicSchema["Enums"][PublicEnumNameOrOptions] ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never : never
export type CompositeTypes< export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends PublicCompositeTypeNameOrOptions extends
| keyof PublicSchema["CompositeTypes"] | keyof DefaultSchema["CompositeTypes"]
| { schema: SchemaName }, | { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: SchemaName schema: keyof DatabaseWithoutInternals
} }
? keyof GetSchema<PublicCompositeTypeNameOrOptions["schema"]>["CompositeTypes"] ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never, : never = never,
> = PublicCompositeTypeNameOrOptions extends { schema: SchemaName } > = PublicCompositeTypeNameOrOptions extends {
? GetSchema<PublicCompositeTypeNameOrOptions["schema"]>["CompositeTypes"][CompositeTypeName] schema: keyof DatabaseWithoutInternals
: PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"] }
? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never : never
export const Constants = { export const Constants = {
public: { public: {
Enums: { Enums: {
admin_role: ["pack_creator", "super_admin"],
answer_type: ["yes", "no", "maybe"], 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"], match_type: ["yes_yes", "yes_maybe", "maybe_maybe"],
subscription_status: [
"active",
"cancelled",
"expired",
"billing_issue",
"paused",
],
}, },
}, },
} as const } as const
@@ -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" } }
);
}
});
@@ -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()'
);
+32
View File
@@ -80,6 +80,7 @@
"expo": "~52.0.0", "expo": "~52.0.0",
"expo-apple-authentication": "~7.1.3", "expo-apple-authentication": "~7.1.3",
"expo-asset": "~11.0.5", "expo-asset": "~11.0.5",
"expo-av": "~15.0.2",
"expo-blur": "~14.0.3", "expo-blur": "~14.0.3",
"expo-build-properties": "^0.13.3", "expo-build-properties": "^0.13.3",
"expo-clipboard": "~7.0.1", "expo-clipboard": "~7.0.1",
@@ -101,6 +102,7 @@
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "0.76.9", "react-native": "0.76.9",
"react-native-compressor": "^1.13.0",
"react-native-gesture-handler": "~2.20.0", "react-native-gesture-handler": "~2.20.0",
"react-native-purchases": "^9.6.12", "react-native-purchases": "^9.6.12",
"react-native-reanimated": "~3.16.0", "react-native-reanimated": "~3.16.0",
@@ -10140,6 +10142,23 @@
"react-native": "*" "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": { "node_modules/expo-blur": {
"version": "14.0.3", "version": "14.0.3",
"resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-14.0.3.tgz", "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": { "node_modules/react-native-gesture-handler": {
"version": "2.20.2", "version": "2.20.2",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz",