mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Update
This commit is contained in:
1 parent
71667d6104
commit
f46f0d59df
24 files changed
+2647
-516
No files matched your search
@@ -0,0 +1,84 @@
|
||||
# Admin Dashboard - Agent Guidelines
|
||||
|
||||
**STRICT RULES - DO NOT VIOLATE**
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a React Admin dashboard using:
|
||||
- **Build**: Vite
|
||||
- **Routing**: react-router-dom with component-based routing in `App.tsx`
|
||||
- **Styling**: Tailwind CSS
|
||||
- **State**: React Context (`AuthContext`)
|
||||
- **Backend**: Supabase client in `src/lib/supabase.ts`
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
components/ # Reusable components
|
||||
layout/ # AppLayout, Sidebar, etc.
|
||||
ProtectedRoute.tsx # Route guard
|
||||
contexts/ # React contexts (AuthContext)
|
||||
hooks/ # Custom hooks
|
||||
lib/ # Utilities (supabase)
|
||||
pages/ # Page components
|
||||
content/ # Categories, Packs, Questions
|
||||
users/ # User management (super_admin only)
|
||||
admins/ # Admin management (super_admin only)
|
||||
```
|
||||
|
||||
## Critical Rules
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
Two admin roles exist:
|
||||
- `admin` - Content management only (categories, packs, questions)
|
||||
- `super_admin` - Full access including user data, admins, audit logs
|
||||
|
||||
Use `<ProtectedRoute requireSuperAdmin>` for super_admin-only routes.
|
||||
|
||||
### Route Structure
|
||||
|
||||
```typescript
|
||||
// Public
|
||||
/login
|
||||
|
||||
// Protected (all admins)
|
||||
/ # Dashboard
|
||||
/categories # Category management
|
||||
/categories/:id/packs # Packs in category
|
||||
/packs/:id/questions # Questions in pack
|
||||
|
||||
// Protected (super_admin only)
|
||||
/users # User list
|
||||
/users/:id # User detail
|
||||
/users/:id/matches/:id # Match chat view
|
||||
/admins # Admin management
|
||||
/audit-logs # Audit log viewer
|
||||
```
|
||||
|
||||
### Supabase Queries
|
||||
|
||||
- **Always use `.maybeSingle()` instead of `.single()`** when the row might not exist
|
||||
- All admin operations are audited - check `audit_logs` table structure
|
||||
|
||||
### UI Patterns
|
||||
|
||||
- Use Tailwind CSS for styling
|
||||
- Follow existing component patterns in `src/components/`
|
||||
- Use `AppLayout` wrapper for all protected pages
|
||||
|
||||
### Security
|
||||
|
||||
- Never expose user passwords or sensitive data in admin views
|
||||
- Audit log all administrative actions
|
||||
- Verify admin role before performing operations
|
||||
|
||||
## Forbidden Actions
|
||||
|
||||
1. **DO NOT** expose user passwords or auth tokens
|
||||
2. **DO NOT** allow content admins to access user data
|
||||
3. **DO NOT** bypass role checks in protected routes
|
||||
4. **DO NOT** perform destructive operations without confirmation
|
||||
5. **DO NOT** use `.single()` for queries that might return 0 rows
|
||||
6. **DO NOT** skip audit logging for administrative actions
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift } from 'lucide-react';
|
||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, HardDrive, ExternalLink } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface Profile {
|
||||
@@ -77,6 +77,18 @@ interface Match {
|
||||
message_count?: number;
|
||||
}
|
||||
|
||||
interface MediaFile {
|
||||
id: string;
|
||||
name: string;
|
||||
bucket_id: string;
|
||||
created_at: string | null;
|
||||
metadata: {
|
||||
size: number;
|
||||
mimetype: string;
|
||||
};
|
||||
signedUrl?: string;
|
||||
}
|
||||
|
||||
const answerIcons = {
|
||||
yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
|
||||
no: <ThumbsDown className="h-4 w-4 text-red-500" />,
|
||||
@@ -89,12 +101,22 @@ const matchTypeLabels = {
|
||||
maybe_maybe: 'Both Maybe',
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export function UserDetailPage() {
|
||||
const { userId } = useParams<{ userId: string }>();
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [partner, setPartner] = useState<Partner | null>(null);
|
||||
const [responses, setResponses] = useState<Response[]>([]);
|
||||
const [matches, setMatches] = useState<Match[]>([]);
|
||||
const [media, setMedia] = useState<MediaFile[]>([]);
|
||||
const [totalStorage, setTotalStorage] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [upgradeOpen, setUpgradeOpen] = useState(false);
|
||||
@@ -239,6 +261,31 @@ export function UserDetailPage() {
|
||||
})) as unknown as Match[]
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch media files uploaded by this user
|
||||
const { data: mediaData } = await supabase
|
||||
.rpc('get_user_media_files', { user_id: userId });
|
||||
|
||||
if (mediaData && mediaData.length > 0) {
|
||||
// Calculate total storage
|
||||
const total = mediaData.reduce((sum: number, file: MediaFile) =>
|
||||
sum + (file.metadata?.size || 0), 0);
|
||||
setTotalStorage(total);
|
||||
|
||||
// Get signed URLs for each file
|
||||
const mediaWithUrls = await Promise.all(
|
||||
mediaData.map(async (file: MediaFile) => {
|
||||
const { data: signedData } = await supabase.storage
|
||||
.from('chat-media')
|
||||
.createSignedUrl(file.name, 3600); // 1 hour expiry
|
||||
return {
|
||||
...file,
|
||||
signedUrl: signedData?.signedUrl,
|
||||
};
|
||||
})
|
||||
);
|
||||
setMedia(mediaWithUrls);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user data:', error);
|
||||
} finally {
|
||||
@@ -392,6 +439,9 @@ export function UserDetailPage() {
|
||||
<TabsTrigger value="matches" disabled={!profile.couple_id}>
|
||||
Matches ({matches.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="media">
|
||||
Media ({media.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="responses" className="mt-4">
|
||||
@@ -479,6 +529,79 @@ export function UserDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="media" className="mt-4">
|
||||
{media.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
<Image className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No media uploaded</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Storage Summary */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Total Storage Used</p>
|
||||
<p className="text-2xl font-bold">{formatBytes(totalStorage)}</p>
|
||||
</div>
|
||||
<div className="ml-auto text-sm text-muted-foreground">
|
||||
{media.length} file{media.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Media Grid */}
|
||||
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{media.map((file) => (
|
||||
<Card key={file.id} className="overflow-hidden">
|
||||
<div className="aspect-square bg-muted relative">
|
||||
{file.signedUrl ? (
|
||||
<img
|
||||
src={file.signedUrl}
|
||||
alt={file.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Image className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="truncate text-muted-foreground">
|
||||
{formatBytes(file.metadata?.size || 0)}
|
||||
</div>
|
||||
{file.signedUrl && (
|
||||
<a
|
||||
href={file.signedUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{file.created_at
|
||||
? format(new Date(file.created_at), 'MMM d, yyyy')
|
||||
: '—'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Search, Crown, Users, ChevronRight } from 'lucide-react';
|
||||
import { Search, Crown, Users, ChevronRight, HardDrive } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface Profile {
|
||||
@@ -25,6 +25,15 @@ interface Profile {
|
||||
is_premium: boolean | null;
|
||||
couple_id: string | null;
|
||||
created_at: string | null;
|
||||
storage_bytes?: number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '—';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
@@ -37,14 +46,34 @@ export function UsersPage() {
|
||||
const fetchProfiles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
// Fetch profiles
|
||||
const { data: profilesData, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setProfiles(data || []);
|
||||
setFilteredProfiles(data || []);
|
||||
|
||||
// Fetch storage usage per user from storage.objects
|
||||
const { data: storageData } = await supabase
|
||||
.rpc('get_user_storage_usage');
|
||||
|
||||
// Create a map of user_id -> storage_bytes
|
||||
const storageMap: Record<string, number> = {};
|
||||
if (storageData) {
|
||||
storageData.forEach((row: { owner: string; total_bytes: number }) => {
|
||||
storageMap[row.owner] = row.total_bytes;
|
||||
});
|
||||
}
|
||||
|
||||
// Merge storage data with profiles
|
||||
const profilesWithStorage = (profilesData || []).map(profile => ({
|
||||
...profile,
|
||||
storage_bytes: storageMap[profile.id] || 0,
|
||||
}));
|
||||
|
||||
setProfiles(profilesWithStorage);
|
||||
setFilteredProfiles(profilesWithStorage);
|
||||
} catch (error) {
|
||||
console.error('Failed to load profiles:', error);
|
||||
} finally {
|
||||
@@ -114,6 +143,7 @@ export function UsersPage() {
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Couple</TableHead>
|
||||
<TableHead>Storage</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
</TableRow>
|
||||
@@ -121,7 +151,7 @@ export function UsersPage() {
|
||||
<TableBody>
|
||||
{filteredProfiles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
{search ? 'No users found matching your search.' : 'No users yet.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -166,6 +196,16 @@ export function UsersPage() {
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{profile.storage_bytes && profile.storage_bytes > 0 ? (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<HardDrive className="h-3 w-3 text-muted-foreground" />
|
||||
{formatBytes(profile.storage_bytes)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{profile.created_at
|
||||
? format(new Date(profile.created_at), 'MMM d, yyyy')
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Mobile App - Agent Guidelines
|
||||
|
||||
**STRICT RULES - DO NOT VIOLATE**
|
||||
|
||||
## Architecture
|
||||
|
||||
This is an Expo React Native app using:
|
||||
- **Routing**: expo-router with file-based routing in `app/` directory
|
||||
- **State**: Zustand stores in `src/store/index.ts`
|
||||
- **Backend**: Supabase client in `src/lib/supabase.ts`
|
||||
- **UI**: Glass-morphism themed components in `src/components/ui/`
|
||||
- **Theme**: Centralized theme in `src/theme/index.ts`
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
app/ # Expo Router routes
|
||||
(auth)/ # Authentication screens
|
||||
(app)/ # Protected app screens (tabs)
|
||||
src/
|
||||
components/ # Reusable components
|
||||
ui/ # Base UI components (GlassCard, GlassButton, etc.)
|
||||
lib/ # Utilities (supabase, analytics, revenuecat)
|
||||
store/ # Zustand stores (index.ts)
|
||||
theme/ # Theme constants (colors, gradients, spacing, etc.)
|
||||
types/ # TypeScript types
|
||||
```
|
||||
|
||||
## Critical Rules
|
||||
|
||||
### Tab Bar - NEVER CHANGE
|
||||
|
||||
**NEVER set `backgroundColor: 'transparent'` on the tab bar.**
|
||||
|
||||
Always use:
|
||||
```typescript
|
||||
backgroundColor: 'rgba(18, 18, 18, 0.85)',
|
||||
```
|
||||
|
||||
The BlurView in `tabBarBackground` is unreliable. The solid rgba background acts as a fallback.
|
||||
|
||||
Location: `app/(app)/_layout.tsx` - `tabBarStyle`
|
||||
|
||||
### State Management
|
||||
|
||||
- Use existing Zustand stores: `useAuthStore`, `useMatchStore`, `usePacksStore`, `useMessageStore`, `useSubscriptionStore`
|
||||
- Access cross-store state via `getState()` (e.g., `useAuthStore.getState().user`)
|
||||
- Clear related stores on sign out (see `signOut` in `useAuthStore`)
|
||||
|
||||
### Supabase Queries
|
||||
|
||||
- **Always use `.maybeSingle()` instead of `.single()`** when the row might not exist
|
||||
- `.single()` throws an error if 0 or >1 rows are returned
|
||||
- Check for null before performing operations
|
||||
|
||||
### UI Components
|
||||
|
||||
Use existing UI primitives from `src/components/ui/`:
|
||||
- `GlassCard` - Glass-morphism card container
|
||||
- `GlassButton` - Glass-styled button
|
||||
- `GlassInput` - Glass-styled input field
|
||||
- `GradientBackground` - Screen background gradient
|
||||
- `ShimmerEffect` - Loading shimmer animation
|
||||
|
||||
### Theme Usage
|
||||
|
||||
Import from `src/theme/index.ts`:
|
||||
```typescript
|
||||
import { colors, gradients, spacing, radius, typography, shadows } from '@/theme';
|
||||
```
|
||||
|
||||
**DO NOT hardcode colors**. Always use theme values.
|
||||
|
||||
### Response Handling
|
||||
|
||||
- Responses use UPSERT with `onConflict: "user_id,question_id"`
|
||||
- Match detection happens in `submit-response` edge function
|
||||
|
||||
### Typing
|
||||
|
||||
- Import shared types from `@/types`
|
||||
- Import Supabase types from `@/types/supabase`
|
||||
|
||||
### RevenueCat
|
||||
|
||||
- RevenueCat service in `src/lib/revenuecat.ts`
|
||||
- Does not work in Expo Go - check `revenueCatService.isAvailable()` first
|
||||
- Initialize with user ID after auth
|
||||
|
||||
### Analytics
|
||||
|
||||
- Use `Events` from `src/lib/analytics` for tracking
|
||||
- Track: `packEnabled`, `packDisabled`, `signOut`, etc.
|
||||
|
||||
## Forbidden Actions
|
||||
|
||||
1. **DO NOT** change tab bar background to transparent
|
||||
2. **DO NOT** use `.single()` for queries that might return 0 rows
|
||||
3. **DO NOT** hardcode colors - use theme values
|
||||
4. **DO NOT** create new stores without integrating with auth sign-out cleanup
|
||||
5. **DO NOT** bypass RevenueCat availability checks
|
||||
6. **DO NOT** add navigation logic outside of expo-router
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useEffect } from "react";
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, useWindowDimensions } from "react-native";
|
||||
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated";
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
FadeInRight,
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
Easing,
|
||||
} from "react-native-reanimated";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
@@ -25,6 +33,28 @@ export default function HomeScreen() {
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
const enabledPacksCount = packs.length;
|
||||
|
||||
// Subtle pulsating animation for the CTA icon
|
||||
const pulseScale = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
const startPulse = () => {
|
||||
pulseScale.value = 1;
|
||||
pulseScale.value = withRepeat(
|
||||
withTiming(1.1, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
};
|
||||
const timeout = setTimeout(startPulse, 100);
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
const pulseAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ scale: pulseScale.value }],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<ScrollView
|
||||
@@ -191,9 +221,9 @@ export default function HomeScreen() {
|
||||
Swipe through questions and discover what you both enjoy
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.ctaIconContainer}>
|
||||
<Animated.View style={[styles.ctaIconContainer, pulseAnimatedStyle]}>
|
||||
<Ionicons name="flame" size={24} color={colors.text} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native";
|
||||
import { useMatchStore, useAuthStore } from "../../src/store";
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { useEffect, useCallback, useState } from "react";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
@@ -8,6 +8,8 @@ import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated";
|
||||
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
||||
import MatchesTutorial from "../../src/components/MatchesTutorial";
|
||||
import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen";
|
||||
|
||||
const MAX_CONTENT_WIDTH = 500;
|
||||
|
||||
@@ -17,6 +19,27 @@ export default function MatchesScreen() {
|
||||
const router = useRouter();
|
||||
const { width } = useWindowDimensions();
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
const [showTutorial, setShowTutorial] = useState(false);
|
||||
|
||||
// Check if tutorial should be shown when screen is focused or matches change
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const checkTutorial = async () => {
|
||||
if (matches.length > 0) {
|
||||
const seen = await hasSeenMatchesTutorial();
|
||||
if (!seen) {
|
||||
setShowTutorial(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
checkTutorial();
|
||||
}, [matches.length])
|
||||
);
|
||||
|
||||
const handleTutorialComplete = async () => {
|
||||
await markMatchesTutorialSeen();
|
||||
setShowTutorial(false);
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -187,6 +210,11 @@ export default function MatchesScreen() {
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Matches Tutorial Overlay */}
|
||||
{showTutorial && (
|
||||
<MatchesTutorial onComplete={handleTutorialComplete} />
|
||||
)}
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -313,7 +313,6 @@ export default function OnboardingScreen() {
|
||||
</View>
|
||||
|
||||
<GlassCard style={styles.card}>
|
||||
<Text style={styles.label}>I'm using Sauci to...</Text>
|
||||
<View style={styles.purposeList}>
|
||||
{PURPOSE_OPTIONS.map((option) => (
|
||||
<Pressable
|
||||
|
||||
+137
-267
@@ -1,24 +1,23 @@
|
||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Switch, RefreshControl, Platform, Alert } from "react-native";
|
||||
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 } from "expo-router";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import Animated, { FadeInDown, FadeInRight, FadeIn, FadeOut, Layout } from "react-native-reanimated";
|
||||
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||
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 { colors, spacing, typography, radius } from "../../src/theme";
|
||||
import type { QuestionPack, Category } from "../../src/types";
|
||||
|
||||
interface CategoryWithPacks extends Category {
|
||||
packs: QuestionPack[];
|
||||
}
|
||||
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 [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
const [selectedFilter, setSelectedFilter] = useState<FilterType>('all');
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
const [showTeaser, setShowTeaser] = useState(false);
|
||||
const [teaserPack, setTeaserPack] = useState<QuestionPack | null>(null);
|
||||
@@ -32,6 +31,47 @@ export default function PacksScreen() {
|
||||
fetchPacks();
|
||||
}, []);
|
||||
|
||||
// Apply category filter from URL param
|
||||
useEffect(() => {
|
||||
if (category) {
|
||||
setSelectedFilter(category);
|
||||
}
|
||||
}, [category]);
|
||||
|
||||
// Filter packs by selected filter (all, enabled, or category id)
|
||||
const filteredPacks = useMemo(() => {
|
||||
if (selectedFilter === 'all') return packs;
|
||||
if (selectedFilter === 'enabled') return packs.filter(p => enabledPackIds.includes(p.id));
|
||||
// Otherwise it's a category id
|
||||
return packs.filter(p => p.category_id === selectedFilter);
|
||||
}, [packs, selectedFilter, enabledPackIds]);
|
||||
|
||||
// Split packs into two columns for masonry layout
|
||||
const { leftColumn, rightColumn } = useMemo(() => {
|
||||
const left: { pack: QuestionPack; index: number }[] = [];
|
||||
const right: { pack: QuestionPack; index: number }[] = [];
|
||||
let leftHeight = 0;
|
||||
let rightHeight = 0;
|
||||
|
||||
filteredPacks.forEach((pack, index) => {
|
||||
// Estimate card height based on description length
|
||||
const baseHeight = 180;
|
||||
const descLength = pack.description?.length || 0;
|
||||
const extraHeight = Math.min(Math.ceil(descLength / 40) * 10, 40);
|
||||
const cardHeight = baseHeight + extraHeight;
|
||||
|
||||
if (leftHeight <= rightHeight) {
|
||||
left.push({ pack, index });
|
||||
leftHeight += cardHeight;
|
||||
} else {
|
||||
right.push({ pack, index });
|
||||
rightHeight += cardHeight;
|
||||
}
|
||||
});
|
||||
|
||||
return { leftColumn: left, rightColumn: right };
|
||||
}, [filteredPacks]);
|
||||
|
||||
// Handle toggling a pack - show teaser if premium pack without access
|
||||
const handleTogglePack = useCallback(async (pack: QuestionPack) => {
|
||||
if (pack.is_premium && !hasPremiumAccess) {
|
||||
@@ -52,6 +92,17 @@ export default function PacksScreen() {
|
||||
}
|
||||
}, [hasPremiumAccess, togglePack]);
|
||||
|
||||
// Handle pack press - navigate or show teaser
|
||||
const handlePackPress = useCallback((pack: QuestionPack) => {
|
||||
const isPremiumLocked = pack.is_premium && !hasPremiumAccess;
|
||||
if (isPremiumLocked) {
|
||||
setTeaserPack(pack);
|
||||
setShowTeaser(true);
|
||||
} else {
|
||||
router.push(`/pack/${pack.id}`);
|
||||
}
|
||||
}, [hasPremiumAccess]);
|
||||
|
||||
// Handle teaser unlock button - show paywall
|
||||
const handleTeaserUnlock = useCallback(() => {
|
||||
setShowTeaser(false);
|
||||
@@ -68,155 +119,10 @@ export default function PacksScreen() {
|
||||
const enabledPackCount = enabledPacks.length;
|
||||
const totalQuestions = enabledPacks.reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0);
|
||||
|
||||
// Group packs by category
|
||||
const categoriesWithPacks = useMemo((): CategoryWithPacks[] => {
|
||||
return categories
|
||||
.map(category => ({
|
||||
...category,
|
||||
packs: packs.filter(p => p.category_id === category.id)
|
||||
}))
|
||||
.filter(cat => cat.packs.length > 0);
|
||||
}, [packs, categories]);
|
||||
|
||||
const toggleCategory = useCallback((categoryId: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(categoryId)) {
|
||||
next.delete(categoryId);
|
||||
} else {
|
||||
next.add(categoryId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderPack = (item: QuestionPack, index: number) => {
|
||||
const isEnabled = enabledPackIds.includes(item.id);
|
||||
const isPremiumLocked = item.is_premium && !hasPremiumAccess;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
key={item.id}
|
||||
entering={FadeIn.delay(index * 50).duration(200)}
|
||||
exiting={FadeOut.duration(150)}
|
||||
layout={Layout.springify()}
|
||||
>
|
||||
<View style={styles.packItem}>
|
||||
<TouchableOpacity
|
||||
style={styles.packContent}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
if (isPremiumLocked) {
|
||||
setTeaserPack(item);
|
||||
setShowTeaser(true);
|
||||
} else {
|
||||
router.push(`/pack/${item.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={[
|
||||
styles.packIcon,
|
||||
isEnabled && styles.packIconActive,
|
||||
isPremiumLocked && styles.packIconLocked
|
||||
]}>
|
||||
<Text style={styles.packEmoji}>{item.icon || "📦"}</Text>
|
||||
</View>
|
||||
<View style={styles.packInfo}>
|
||||
<View style={styles.packHeader}>
|
||||
<Text
|
||||
style={[
|
||||
styles.packName,
|
||||
isPremiumLocked && styles.packNameLocked
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.is_premium && (
|
||||
<View style={[
|
||||
styles.badge,
|
||||
isPremiumLocked && styles.badgeLocked
|
||||
]}>
|
||||
<Ionicons
|
||||
name={isPremiumLocked ? "lock-closed" : "star"}
|
||||
size={10}
|
||||
color={colors.text}
|
||||
/>
|
||||
<Text style={styles.badgeText}>PRO</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[
|
||||
styles.packDescription,
|
||||
isPremiumLocked && styles.packDescriptionLocked
|
||||
]} numberOfLines={1}>
|
||||
{isPremiumLocked ? "Upgrade to unlock" : item.description}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={() => handleTogglePack(item)}
|
||||
disabled={isPremiumLocked}
|
||||
trackColor={{
|
||||
false: colors.glass.background,
|
||||
true: colors.primary,
|
||||
}}
|
||||
thumbColor={colors.text}
|
||||
ios_backgroundColor={colors.glass.background}
|
||||
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCategory = ({ item: category, index }: { item: CategoryWithPacks; index: number }) => {
|
||||
const isExpanded = expandedCategories.has(category.id);
|
||||
const enabledInCategory = category.packs.filter(p => enabledPackIds.includes(p.id)).length;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(index * 100).duration(300)}
|
||||
layout={Layout.springify()}
|
||||
>
|
||||
<GlassCard style={styles.categoryCard}>
|
||||
<TouchableOpacity
|
||||
style={styles.categoryHeader}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => toggleCategory(category.id)}
|
||||
>
|
||||
<Text style={styles.categoryIcon}>{category.icon}</Text>
|
||||
<View style={styles.categoryInfo}>
|
||||
<Text style={styles.categoryName}>{category.name}</Text>
|
||||
<Text style={styles.categoryMeta}>
|
||||
{category.packs.length} packs · {enabledInCategory} enabled
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name={isExpanded ? "chevron-up" : "chevron-down"}
|
||||
size={24}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{isExpanded && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(150)}
|
||||
style={styles.packsContainer}
|
||||
>
|
||||
{category.packs.map((pack, idx) => renderPack(pack, idx))}
|
||||
</Animated.View>
|
||||
)}
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(400)}
|
||||
style={styles.header}
|
||||
@@ -227,11 +133,18 @@ export default function PacksScreen() {
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
<FlatList
|
||||
data={categoriesWithPacks}
|
||||
renderItem={renderCategory}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
{/* 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
|
||||
@@ -241,7 +154,9 @@ export default function PacksScreen() {
|
||||
colors={[colors.primary]}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{filteredPacks.length === 0 ? (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(200).duration(400)}
|
||||
style={styles.emptyContainer}
|
||||
@@ -251,12 +166,59 @@ export default function PacksScreen() {
|
||||
</View>
|
||||
<Text style={styles.emptyTitle}>No packs available</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Check back later for new question packs!
|
||||
{selectedFilter === 'enabled'
|
||||
? "No packs enabled yet. Enable some packs to see them here!"
|
||||
: selectedFilter !== 'all'
|
||||
? "No packs in this category. Try selecting a different one."
|
||||
: "Check back later for new question packs!"}
|
||||
</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}
|
||||
@@ -288,7 +250,7 @@ const styles = StyleSheet.create({
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.md,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
...typography.title1,
|
||||
@@ -299,114 +261,22 @@ const styles = StyleSheet.create({
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
list: {
|
||||
padding: spacing.lg,
|
||||
scrollContent: {
|
||||
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
|
||||
},
|
||||
categoryCard: {
|
||||
marginBottom: spacing.md,
|
||||
masonryContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
categoryHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
categoryIcon: {
|
||||
fontSize: 32,
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
categoryInfo: {
|
||||
column: {
|
||||
flex: 1,
|
||||
},
|
||||
categoryName: {
|
||||
...typography.title3,
|
||||
color: colors.text,
|
||||
},
|
||||
categoryMeta: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
packsContainer: {
|
||||
marginTop: spacing.md,
|
||||
paddingTop: spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.glass.border,
|
||||
},
|
||||
packItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
packContent: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
packIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
packIconActive: {
|
||||
backgroundColor: colors.primaryLight,
|
||||
},
|
||||
packIconLocked: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
packEmoji: {
|
||||
fontSize: 22,
|
||||
},
|
||||
packInfo: {
|
||||
flex: 1,
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
packHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 2,
|
||||
},
|
||||
packName: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
marginRight: spacing.sm,
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
packNameLocked: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
packDescription: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
packDescriptionLocked: {
|
||||
color: colors.textTertiary,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.xs,
|
||||
gap: 2,
|
||||
},
|
||||
badgeLocked: {
|
||||
backgroundColor: colors.glass.backgroundLight,
|
||||
},
|
||||
badgeText: {
|
||||
...typography.caption2,
|
||||
color: colors.text,
|
||||
fontWeight: "700",
|
||||
gap: spacing.md,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: "center",
|
||||
paddingVertical: spacing.xxl,
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
width: 80,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import { resetSwipeTutorial } from "../../src/lib/swipeTutorialSeen";
|
||||
import { resetMatchesTutorial } from "../../src/lib/matchesTutorialSeen";
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { decode } from 'base64-arraybuffer';
|
||||
@@ -821,6 +822,60 @@ export default function ProfileScreen() {
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.preferencesDivider} />
|
||||
<TouchableOpacity
|
||||
style={styles.rowContainer}
|
||||
onPress={async () => {
|
||||
await resetMatchesTutorial();
|
||||
Alert.alert("Success", "Matches tutorial has been reset. It will show again next time you visit the matches screen.");
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.rowLeft}>
|
||||
<View style={styles.debugIcon}>
|
||||
<Ionicons name="heart" size={20} color={colors.warning} />
|
||||
</View>
|
||||
<View style={styles.rowTextContainer}>
|
||||
<Text style={styles.rowValue}>Reset Matches Tutorial</Text>
|
||||
<Text style={styles.rowLabel}>Show the tutorial again</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.chevronContainer}>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.preferencesDivider} />
|
||||
<TouchableOpacity
|
||||
style={styles.rowContainer}
|
||||
onPress={async () => {
|
||||
if (!user?.id) return;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('profiles')
|
||||
.update({ onboarding_completed: false })
|
||||
.eq('id', user.id);
|
||||
if (error) throw error;
|
||||
await fetchUser();
|
||||
Alert.alert("Success", "Onboarding has been reset. You will see the onboarding flow on next app launch.");
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to reset onboarding.");
|
||||
}
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.rowLeft}>
|
||||
<View style={styles.debugIcon}>
|
||||
<Ionicons name="school-outline" size={20} color={colors.warning} />
|
||||
</View>
|
||||
<View style={styles.rowTextContainer}>
|
||||
<Text style={styles.rowValue}>Reset Onboarding</Text>
|
||||
<Text style={styles.rowLabel}>Show onboarding flow again</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.chevronContainer}>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
+429
-233
@@ -1,9 +1,14 @@
|
||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator, Modal, Pressable } from "react-native";
|
||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator, Modal, Pressable, Platform } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import { usePacksStore } from "../../src/store";
|
||||
import { usePacksStore, useSubscriptionStore, useAuthStore } from "../../src/store";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Animated, { FadeInDown, FadeIn } from "react-native-reanimated";
|
||||
import { GradientBackground, GlassCard, ShimmerEffect } from "../../src/components/ui";
|
||||
import { colors, gradients, spacing, radius, typography, blur, shadows } from "../../src/theme";
|
||||
import { Question } from "../../src/types";
|
||||
|
||||
type TooltipType = 'initiator' | 'coupleType' | null;
|
||||
@@ -19,15 +24,31 @@ const TOOLTIP_CONTENT = {
|
||||
},
|
||||
};
|
||||
|
||||
// Boutique gradient for question cards
|
||||
const QUESTION_GRADIENTS: Array<readonly [string, string]> = [
|
||||
['rgba(233, 69, 96, 0.15)', 'rgba(155, 89, 182, 0.15)'],
|
||||
['rgba(155, 89, 182, 0.15)', 'rgba(233, 69, 96, 0.15)'],
|
||||
['rgba(183, 110, 121, 0.12)', 'rgba(139, 69, 87, 0.12)'],
|
||||
['rgba(142, 68, 173, 0.12)', 'rgba(44, 62, 80, 0.12)'],
|
||||
];
|
||||
|
||||
export default function PackDetailsScreen() {
|
||||
const { id } = useLocalSearchParams();
|
||||
const router = useRouter();
|
||||
const { packs, fetchPacks } = usePacksStore();
|
||||
const { subscription } = useSubscriptionStore();
|
||||
const { user, partner } = useAuthStore();
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTooltip, setActiveTooltip] = useState<TooltipType>(null);
|
||||
|
||||
const pack = packs.find((p) => p.id === id);
|
||||
const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser;
|
||||
const isPremium = pack?.is_premium && hasPremiumAccess;
|
||||
|
||||
// Get category for breadcrumb
|
||||
const { categories } = usePacksStore();
|
||||
const category = categories.find(c => c.id === pack?.category_id);
|
||||
|
||||
useEffect(() => {
|
||||
if (packs.length === 0) {
|
||||
@@ -53,311 +74,486 @@ export default function PackDetailsScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: Question }) => (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.questionText}>{item.text}</Text>
|
||||
{item.partner_text && (
|
||||
<Text style={styles.partnerText}>Partner role: {item.partner_text}</Text>
|
||||
)}
|
||||
<View style={styles.footerContainer}>
|
||||
<View style={styles.intensityContainer}>
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<Ionicons
|
||||
key={index}
|
||||
name="flame"
|
||||
size={12}
|
||||
color={index < item.intensity ? "#e94560" : "#333"}
|
||||
style={{ marginRight: 2 }}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{/* Initiator indicator */}
|
||||
{item.target_user_genders && item.target_user_genders.length > 0 ? (
|
||||
<View style={styles.targetsContainer}>
|
||||
<Pressable
|
||||
style={[styles.targetBadge, styles.initiatorBadge]}
|
||||
onPress={() => setActiveTooltip('initiator')}
|
||||
>
|
||||
<Ionicons name="arrow-forward" size={12} color="#f39c12" style={{ marginRight: 4 }} />
|
||||
{item.target_user_genders.map((g, i) => (
|
||||
<Ionicons
|
||||
key={g}
|
||||
name={g === 'male' ? 'male' : g === 'female' ? 'female' : 'person'}
|
||||
size={14}
|
||||
color="#f39c12"
|
||||
style={i > 0 ? { marginLeft: 4 } : {}}
|
||||
/>
|
||||
))}
|
||||
<Ionicons name="help-circle-outline" size={14} color="#f39c12" style={{ marginLeft: 6 }} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
{/* Couple type indicator */}
|
||||
{item.allowed_couple_genders && item.allowed_couple_genders.length > 0 ? (
|
||||
<Pressable
|
||||
style={styles.targetsContainer}
|
||||
onPress={() => setActiveTooltip('coupleType')}
|
||||
>
|
||||
{item.allowed_couple_genders.map((g) => {
|
||||
const icons = [];
|
||||
if (g === 'male+male') {
|
||||
icons.push('male', 'male');
|
||||
} else if (g === 'female+female') {
|
||||
icons.push('female', 'female');
|
||||
} else {
|
||||
icons.push('male', 'female');
|
||||
}
|
||||
const useBlur = Platform.OS === 'ios';
|
||||
|
||||
return (
|
||||
<View key={g} style={styles.targetBadge}>
|
||||
{icons.map((icon, i) => (
|
||||
<Ionicons
|
||||
key={i}
|
||||
name={icon as any}
|
||||
size={14}
|
||||
color="#ccc"
|
||||
style={i > 0 ? { marginLeft: 4 } : {}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<Ionicons name="help-circle-outline" size={14} color="#ccc" style={{ marginLeft: 6 }} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable
|
||||
style={styles.targetsContainer}
|
||||
onPress={() => setActiveTooltip('coupleType')}
|
||||
>
|
||||
<View style={styles.targetBadge}>
|
||||
<Ionicons name="people" size={14} color="#ccc" />
|
||||
const renderItem = ({ item, index }: { item: Question; index: number }) => {
|
||||
const cardGradient = QUESTION_GRADIENTS[index % QUESTION_GRADIENTS.length];
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInDown.delay(index * 50).duration(300)}>
|
||||
<GlassCard 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}>
|
||||
<Text style={styles.questionText}>{item.text}</Text>
|
||||
|
||||
{item.partner_text && (
|
||||
<View style={styles.partnerTextContainer}>
|
||||
<Ionicons name="swap-horizontal" size={14} color={colors.premium.rose} />
|
||||
<Text style={styles.partnerText}>{item.partner_text}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.footerContainer}>
|
||||
{/* Intensity */}
|
||||
<View style={styles.intensityContainer}>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Ionicons
|
||||
key={i}
|
||||
name="flame"
|
||||
size={14}
|
||||
color={i < item.intensity ? colors.primary : colors.textTertiary}
|
||||
style={{ marginRight: 2 }}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.badgesContainer}>
|
||||
{/* Initiator indicator */}
|
||||
{item.target_user_genders && item.target_user_genders.length > 0 && (
|
||||
<Pressable
|
||||
style={styles.targetBadge}
|
||||
onPress={() => setActiveTooltip('initiator')}
|
||||
>
|
||||
<Ionicons name="arrow-forward" size={12} color={colors.premium.gold} style={{ marginRight: 4 }} />
|
||||
{item.target_user_genders.map((g, i) => (
|
||||
<Ionicons
|
||||
key={g}
|
||||
name={g === 'male' ? 'male' : g === 'female' ? 'female' : 'person'}
|
||||
size={14}
|
||||
color={colors.premium.gold}
|
||||
style={i > 0 ? { marginLeft: 4 } : {}}
|
||||
/>
|
||||
))}
|
||||
<Ionicons name="help-circle-outline" size={12} color={colors.premium.gold} style={{ marginLeft: 4 }} />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Couple type indicator */}
|
||||
<Pressable
|
||||
style={styles.targetBadge}
|
||||
onPress={() => setActiveTooltip('coupleType')}
|
||||
>
|
||||
{item.allowed_couple_genders && item.allowed_couple_genders.length > 0 ? (
|
||||
item.allowed_couple_genders.map((g) => {
|
||||
const icons: ('male' | 'female')[] = [];
|
||||
if (g === 'male+male') {
|
||||
icons.push('male', 'male');
|
||||
} else if (g === 'female+female') {
|
||||
icons.push('female', 'female');
|
||||
} else {
|
||||
icons.push('male', 'female');
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={g} style={styles.coupleIconGroup}>
|
||||
{icons.map((icon, i) => (
|
||||
<Ionicons
|
||||
key={i}
|
||||
name={icon}
|
||||
size={14}
|
||||
color={colors.textSecondary}
|
||||
style={i > 0 ? { marginLeft: 2 } : {}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Ionicons name="people" size={14} color={colors.textSecondary} />
|
||||
)}
|
||||
<Ionicons name="help-circle-outline" size={12} color={colors.textTertiary} style={{ marginLeft: 4 }} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<Ionicons name="help-circle-outline" size={14} color="#ccc" style={{ marginLeft: 6 }} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<GradientBackground>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#e94560" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
<Ionicons name="arrow-back" size={24} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>{pack?.name || "Pack Details"}</Text>
|
||||
<View style={{ width: 24 }} />
|
||||
</View>
|
||||
<GradientBackground>
|
||||
<View style={styles.container}>
|
||||
{/* Hero Section */}
|
||||
<Animated.View entering={FadeIn.duration(400)}>
|
||||
{isPremium ? (
|
||||
<ShimmerEffect enabled={true} style={styles.heroContainer}>
|
||||
{heroContent}
|
||||
</ShimmerEffect>
|
||||
) : (
|
||||
<View style={styles.heroContainer}>
|
||||
{heroContent}
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{pack && (
|
||||
<View style={styles.packInfo}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Text style={styles.emoji}>{pack.icon || "📦"}</Text>
|
||||
</View>
|
||||
<Text style={styles.description}>{pack.description}</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Questions List */}
|
||||
<FlatList
|
||||
data={questions}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<Animated.View entering={FadeInDown.delay(100).duration(300)} style={styles.emptyContainer}>
|
||||
<Ionicons name="help-circle-outline" size={48} color={colors.textTertiary} />
|
||||
<Text style={styles.emptyText}>No questions in this pack yet</Text>
|
||||
</Animated.View>
|
||||
}
|
||||
/>
|
||||
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Questions ({questions.length})</Text>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={questions}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
/>
|
||||
|
||||
{/* Tooltip Modal */}
|
||||
<Modal
|
||||
visible={activeTooltip !== null}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setActiveTooltip(null)}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.modalOverlay}
|
||||
onPress={() => setActiveTooltip(null)}
|
||||
{/* Tooltip Modal */}
|
||||
<Modal
|
||||
visible={activeTooltip !== null}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setActiveTooltip(null)}
|
||||
>
|
||||
<View style={styles.tooltipContainer}>
|
||||
<Text style={styles.tooltipTitle}>
|
||||
{activeTooltip && TOOLTIP_CONTENT[activeTooltip].title}
|
||||
</Text>
|
||||
<Text style={styles.tooltipDescription}>
|
||||
{activeTooltip && TOOLTIP_CONTENT[activeTooltip].description}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.tooltipButton}
|
||||
onPress={() => setActiveTooltip(null)}
|
||||
>
|
||||
<Text style={styles.tooltipButtonText}>Got it</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.modalOverlay}
|
||||
onPress={() => setActiveTooltip(null)}
|
||||
>
|
||||
<GlassCard style={styles.tooltipContainer}>
|
||||
<LinearGradient
|
||||
colors={gradients.laceTint as [string, string]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<Text style={styles.tooltipTitle}>
|
||||
{activeTooltip && TOOLTIP_CONTENT[activeTooltip].title}
|
||||
</Text>
|
||||
<Text style={styles.tooltipDescription}>
|
||||
{activeTooltip && TOOLTIP_CONTENT[activeTooltip].description}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.tooltipButton}
|
||||
onPress={() => setActiveTooltip(null)}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.tooltipButtonGradient}
|
||||
>
|
||||
<Text style={styles.tooltipButtonText}>Got it</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</GlassCard>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</View>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#1a1a2e",
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#1a1a2e",
|
||||
},
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 24,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#16213e",
|
||||
heroContainer: {
|
||||
borderBottomLeftRadius: radius.xl,
|
||||
borderBottomRightRadius: radius.xl,
|
||||
overflow: 'hidden',
|
||||
...shadows.lg,
|
||||
},
|
||||
heroInner: {
|
||||
paddingTop: 50,
|
||||
paddingBottom: spacing.lg,
|
||||
minHeight: 260,
|
||||
},
|
||||
heroBlur: {
|
||||
backgroundColor: 'rgba(22, 33, 62, 0.4)',
|
||||
},
|
||||
heroBlurFallback: {
|
||||
backgroundColor: 'rgba(22, 33, 62, 0.6)',
|
||||
},
|
||||
backButton: {
|
||||
padding: 4,
|
||||
position: 'absolute',
|
||||
top: 50,
|
||||
left: spacing.md,
|
||||
zIndex: 10,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
color: "#fff",
|
||||
backButtonInner: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
packInfo: {
|
||||
padding: 24,
|
||||
alignItems: "center",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#16213e",
|
||||
heroContent: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.xl,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
},
|
||||
iconContainerPremium: {
|
||||
borderColor: colors.premium.gold,
|
||||
backgroundColor: colors.premium.goldLight,
|
||||
},
|
||||
emoji: {
|
||||
fontSize: 32,
|
||||
fontSize: 40,
|
||||
},
|
||||
proBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.premium.gold,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: radius.sm,
|
||||
gap: 4,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
proBadgeText: {
|
||||
...typography.caption1,
|
||||
color: colors.text,
|
||||
fontWeight: '700',
|
||||
},
|
||||
breadcrumb: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
breadcrumbText: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
title: {
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
color: "#ccc",
|
||||
textAlign: "center",
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
sectionHeader: {
|
||||
padding: 24,
|
||||
paddingBottom: 8,
|
||||
statsBadge: {
|
||||
marginTop: spacing.md,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
color: "#e94560",
|
||||
statsText: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
list: {
|
||||
padding: 24,
|
||||
padding: spacing.md,
|
||||
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: "#16213e",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: "#0f3460",
|
||||
questionCard: {
|
||||
marginBottom: spacing.md,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
questionContent: {
|
||||
padding: spacing.md,
|
||||
},
|
||||
questionText: {
|
||||
fontSize: 16,
|
||||
color: "#fff",
|
||||
marginBottom: 8,
|
||||
lineHeight: 22,
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.sm,
|
||||
lineHeight: 24,
|
||||
},
|
||||
intensityContainer: {
|
||||
flexDirection: "row",
|
||||
partnerTextContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
marginBottom: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
backgroundColor: colors.premium.roseLight,
|
||||
borderRadius: radius.sm,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
partnerText: {
|
||||
fontSize: 14,
|
||||
color: "#aaa",
|
||||
fontStyle: "italic",
|
||||
marginBottom: 8,
|
||||
...typography.caption1,
|
||||
color: colors.premium.rose,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
footerContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 8,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
targetsContainer: {
|
||||
intensityContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
badgesContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
targetBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
initiatorBadge: {
|
||||
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
||||
borderColor: 'rgba(243, 156, 18, 0.3)',
|
||||
coupleIconGroup: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xxl,
|
||||
gap: spacing.md,
|
||||
},
|
||||
emptyText: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
tooltipContainer: {
|
||||
backgroundColor: '#16213e',
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
maxWidth: 320,
|
||||
borderWidth: 1,
|
||||
borderColor: '#0f3460',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
tooltipTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
marginBottom: 12,
|
||||
...typography.title3,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
tooltipDescription: {
|
||||
fontSize: 14,
|
||||
color: '#ccc',
|
||||
lineHeight: 22,
|
||||
marginBottom: 20,
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 24,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
tooltipButton: {
|
||||
backgroundColor: '#e94560',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
borderRadius: radius.md,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
tooltipButtonGradient: {
|
||||
paddingVertical: spacing.sm,
|
||||
alignItems: 'center',
|
||||
},
|
||||
tooltipButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Text, TouchableOpacity, Switch, Platform } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
useSharedValue,
|
||||
FadeIn,
|
||||
} from 'react-native-reanimated';
|
||||
import { ShimmerEffect } from './ui/ShimmerEffect';
|
||||
import { colors, gradients, spacing, radius, typography, blur, shadows, animations } from '../theme';
|
||||
import type { QuestionPack } from '../types';
|
||||
|
||||
// Boutique gradient palette for pack cards
|
||||
const BOUTIQUE_GRADIENTS: Array<readonly [string, string]> = [
|
||||
gradients.boutiqueRose as [string, string],
|
||||
gradients.boutiquePurple as [string, string],
|
||||
gradients.boutiqueDusty as [string, string],
|
||||
gradients.boutiqueAmethyst as [string, string],
|
||||
gradients.boutiqueMidnight as [string, string],
|
||||
gradients.boutiqueGold as [string, string],
|
||||
];
|
||||
|
||||
interface BoutiquePackCardProps {
|
||||
pack: QuestionPack;
|
||||
index: number;
|
||||
isEnabled: boolean;
|
||||
isPremiumLocked: boolean;
|
||||
onPress: () => void;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity);
|
||||
|
||||
export function BoutiquePackCard({
|
||||
pack,
|
||||
index,
|
||||
isEnabled,
|
||||
isPremiumLocked,
|
||||
onPress,
|
||||
onToggle,
|
||||
}: BoutiquePackCardProps) {
|
||||
const scale = useSharedValue(1);
|
||||
const cardGradient = BOUTIQUE_GRADIENTS[index % BOUTIQUE_GRADIENTS.length];
|
||||
const questionCount = pack.questions?.[0]?.count || 0;
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
const handlePressIn = () => {
|
||||
scale.value = withSpring(0.97, animations.spring);
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
scale.value = withSpring(1, animations.spring);
|
||||
};
|
||||
|
||||
const useBlur = Platform.OS === 'ios';
|
||||
|
||||
const cardContent = (
|
||||
<View style={styles.cardInner}>
|
||||
{/* Background gradient */}
|
||||
<LinearGradient
|
||||
colors={cardGradient}
|
||||
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.glassOverlay]}
|
||||
/>
|
||||
) : (
|
||||
<View style={[StyleSheet.absoluteFill, styles.glassOverlayFallback]} />
|
||||
)}
|
||||
|
||||
{/* Silk texture effect */}
|
||||
<LinearGradient
|
||||
colors={gradients.silkLight as [string, string]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[StyleSheet.absoluteFill, { opacity: 0.5 }]}
|
||||
/>
|
||||
|
||||
{/* Top highlight */}
|
||||
<LinearGradient
|
||||
colors={[colors.glass.highlight, 'transparent']}
|
||||
style={styles.topHighlight}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
{/* PRO Badge (top-right) */}
|
||||
{pack.is_premium && (
|
||||
<View style={[
|
||||
styles.proBadge,
|
||||
isPremiumLocked && styles.proBadgeLocked
|
||||
]}>
|
||||
<Ionicons
|
||||
name={isPremiumLocked ? 'lock-closed' : 'star'}
|
||||
size={10}
|
||||
color={colors.text}
|
||||
/>
|
||||
<Text style={styles.proBadgeText}>PRO</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Pack Icon */}
|
||||
<View style={[
|
||||
styles.iconContainer,
|
||||
isEnabled && styles.iconContainerActive,
|
||||
pack.is_premium && !isPremiumLocked && styles.iconContainerPremium,
|
||||
]}>
|
||||
<Text style={styles.packEmoji}>{pack.icon || '📦'}</Text>
|
||||
</View>
|
||||
|
||||
{/* Pack Name */}
|
||||
<Text
|
||||
style={[
|
||||
styles.packName,
|
||||
isPremiumLocked && styles.packNameLocked,
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{pack.name}
|
||||
</Text>
|
||||
|
||||
{/* Description */}
|
||||
<Text
|
||||
style={[
|
||||
styles.packDescription,
|
||||
isPremiumLocked && styles.packDescriptionLocked,
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{isPremiumLocked ? 'Unlock with Pro' : pack.description}
|
||||
</Text>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.questionBadge}>
|
||||
<Text style={styles.questionCount}>{questionCount}</Text>
|
||||
<Text style={styles.questionLabel}>questions</Text>
|
||||
</View>
|
||||
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={onToggle}
|
||||
disabled={isPremiumLocked}
|
||||
trackColor={{
|
||||
false: 'rgba(255, 255, 255, 0.15)',
|
||||
true: pack.is_premium ? colors.premium.gold : colors.primary,
|
||||
}}
|
||||
thumbColor={colors.text}
|
||||
ios_backgroundColor="rgba(255, 255, 255, 0.15)"
|
||||
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Lock overlay for premium locked packs */}
|
||||
{isPremiumLocked && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
style={styles.lockOverlay}
|
||||
>
|
||||
<View style={styles.lockIconContainer}>
|
||||
<Ionicons name="lock-closed" size={28} color={colors.premium.gold} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
const wrappedCard = pack.is_premium && !isPremiumLocked ? (
|
||||
<ShimmerEffect enabled={true} style={styles.shimmerWrapper}>
|
||||
{cardContent}
|
||||
</ShimmerEffect>
|
||||
) : (
|
||||
cardContent
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatedTouchableOpacity
|
||||
style={[
|
||||
styles.card,
|
||||
animatedStyle,
|
||||
pack.is_premium && !isPremiumLocked && styles.cardPremium,
|
||||
isEnabled && styles.cardEnabled,
|
||||
]}
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{wrappedCard}
|
||||
</AnimatedTouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: radius.lg,
|
||||
overflow: 'hidden',
|
||||
...shadows.md,
|
||||
},
|
||||
cardPremium: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.premium.gold,
|
||||
...shadows.glow(colors.premium.goldGlow),
|
||||
},
|
||||
cardEnabled: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
shimmerWrapper: {
|
||||
borderRadius: radius.lg,
|
||||
},
|
||||
cardInner: {
|
||||
minHeight: 180,
|
||||
borderRadius: radius.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
glassOverlay: {
|
||||
backgroundColor: 'rgba(22, 33, 62, 0.3)',
|
||||
},
|
||||
glassOverlayFallback: {
|
||||
backgroundColor: 'rgba(22, 33, 62, 0.5)',
|
||||
},
|
||||
topHighlight: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 40,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: spacing.md,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
proBadge: {
|
||||
position: 'absolute',
|
||||
top: spacing.sm,
|
||||
right: spacing.sm,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.premium.gold,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 3,
|
||||
borderRadius: radius.xs,
|
||||
gap: 3,
|
||||
zIndex: 10,
|
||||
},
|
||||
proBadgeLocked: {
|
||||
backgroundColor: colors.glass.backgroundLight,
|
||||
},
|
||||
proBadgeText: {
|
||||
...typography.caption2,
|
||||
color: colors.text,
|
||||
fontWeight: '700',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
iconContainerActive: {
|
||||
backgroundColor: colors.primaryLight,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
iconContainerPremium: {
|
||||
backgroundColor: colors.premium.goldLight,
|
||||
borderColor: colors.premium.gold,
|
||||
},
|
||||
packEmoji: {
|
||||
fontSize: 28,
|
||||
},
|
||||
packName: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
packNameLocked: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
packDescription: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
flex: 1,
|
||||
},
|
||||
packDescriptionLocked: {
|
||||
color: colors.textTertiary,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
questionBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
gap: 4,
|
||||
},
|
||||
questionCount: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
},
|
||||
questionLabel: {
|
||||
...typography.caption2,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
lockOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
lockIconContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.premium.gold,
|
||||
},
|
||||
});
|
||||
|
||||
export default BoutiquePackCard;
|
||||
@@ -0,0 +1,224 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Text, ScrollView, TouchableOpacity, Platform } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated';
|
||||
import { colors, gradients, spacing, radius, typography, blur, animations } from '../theme';
|
||||
import type { Category } from '../types';
|
||||
|
||||
export type FilterType = 'all' | 'enabled' | string;
|
||||
|
||||
interface CategoryTabsProps {
|
||||
categories: Category[];
|
||||
selectedFilter: FilterType;
|
||||
onSelectFilter: (filter: FilterType) => void;
|
||||
enabledCount?: number;
|
||||
}
|
||||
|
||||
const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity);
|
||||
|
||||
function CategoryTab({
|
||||
label,
|
||||
icon,
|
||||
isSelected,
|
||||
onPress,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
icon?: string | null;
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
badge?: number;
|
||||
}) {
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
const handlePressIn = () => {
|
||||
scale.value = withSpring(0.95, animations.spring);
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
scale.value = withSpring(1, animations.spring);
|
||||
};
|
||||
|
||||
const useBlur = Platform.OS === 'ios';
|
||||
|
||||
const tabInnerContent = (
|
||||
<>
|
||||
{icon === '✓' ? (
|
||||
<Ionicons name="checkmark" size={16} color={colors.text} style={{ marginRight: 2 }} />
|
||||
) : icon ? (
|
||||
<Text style={styles.tabIcon}>{icon}</Text>
|
||||
) : null}
|
||||
<Text style={isSelected ? styles.tabTextSelected : styles.tabText}>{label}</Text>
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<View style={[styles.badge, isSelected && styles.badgeSelected]}>
|
||||
<Text style={styles.badgeText}>{badge}</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatedTouchableOpacity
|
||||
style={[styles.tabContainer, animatedStyle]}
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isSelected ? (
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.tabGradient}
|
||||
>
|
||||
{tabInnerContent}
|
||||
</LinearGradient>
|
||||
) : useBlur ? (
|
||||
<BlurView
|
||||
intensity={blur.light}
|
||||
tint="dark"
|
||||
style={styles.tabBlur}
|
||||
>
|
||||
<View style={styles.tabContent}>
|
||||
{tabInnerContent}
|
||||
</View>
|
||||
</BlurView>
|
||||
) : (
|
||||
<View style={styles.tabFallback}>
|
||||
{tabInnerContent}
|
||||
</View>
|
||||
)}
|
||||
</AnimatedTouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
export function CategoryTabs({
|
||||
categories,
|
||||
selectedFilter,
|
||||
onSelectFilter,
|
||||
enabledCount = 0,
|
||||
}: CategoryTabsProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{/* All tab */}
|
||||
<CategoryTab
|
||||
label="All"
|
||||
isSelected={selectedFilter === 'all'}
|
||||
onPress={() => onSelectFilter('all')}
|
||||
/>
|
||||
|
||||
{/* Enabled tab */}
|
||||
<CategoryTab
|
||||
label="Enabled"
|
||||
icon="✓"
|
||||
isSelected={selectedFilter === 'enabled'}
|
||||
onPress={() => onSelectFilter('enabled')}
|
||||
badge={enabledCount}
|
||||
/>
|
||||
|
||||
{/* Category tabs */}
|
||||
{categories.map((category) => (
|
||||
<CategoryTab
|
||||
key={category.id}
|
||||
label={category.name}
|
||||
icon={category.icon}
|
||||
isSelected={selectedFilter === category.id}
|
||||
onPress={() => onSelectFilter(category.id)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
tabContainer: {
|
||||
borderRadius: radius.full,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
tabGradient: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
tabBlur: {
|
||||
borderRadius: radius.full,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
tabFallback: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.glass.backgroundLight,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
tabContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
tabIcon: {
|
||||
fontSize: 16,
|
||||
},
|
||||
tabText: {
|
||||
...typography.subhead,
|
||||
color: colors.text, // Changed from textSecondary for better accessibility
|
||||
fontWeight: '500',
|
||||
},
|
||||
tabTextSelected: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
badge: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.full,
|
||||
marginLeft: 4,
|
||||
},
|
||||
badgeSelected: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
badgeText: {
|
||||
...typography.caption2,
|
||||
color: colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default CategoryTabs;
|
||||
@@ -0,0 +1,484 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { View, Text, StyleSheet, Pressable, Platform } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
withDelay,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
interpolate,
|
||||
} from "react-native-reanimated";
|
||||
import { colors, gradients, radius, shadows, blur, typography, spacing } from "../theme";
|
||||
import { Events } from "../lib/analytics";
|
||||
|
||||
interface Props {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
interface TutorialStep {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
gradient: [string, string];
|
||||
}
|
||||
|
||||
const STEPS: TutorialStep[] = [
|
||||
{
|
||||
key: "match",
|
||||
title: "You've Got a Match!",
|
||||
description: "When both you and your partner swipe YES or MAYBE on the same question, it creates a match. This means you're both interested!",
|
||||
icon: "heart",
|
||||
gradient: gradients.primary as [string, string],
|
||||
},
|
||||
{
|
||||
key: "chat",
|
||||
title: "Start a Private Chat",
|
||||
description: "Tap any match to open a private conversation. Discuss, plan, and explore your shared interests together.",
|
||||
icon: "chatbubbles",
|
||||
gradient: gradients.success as [string, string],
|
||||
},
|
||||
{
|
||||
key: "photos",
|
||||
title: "Share Pictures Privately",
|
||||
description: "Send photos that only your partner can see. Your intimate moments stay between the two of you, safe and private.",
|
||||
icon: "images",
|
||||
gradient: gradients.premiumRose as [string, string],
|
||||
},
|
||||
];
|
||||
|
||||
export default function MatchesTutorial({ onComplete }: Props) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
|
||||
const pulseScale = useSharedValue(1);
|
||||
const iconBounce = useSharedValue(0);
|
||||
|
||||
const currentStepData = STEPS[currentStep];
|
||||
|
||||
// Track tutorial started on mount
|
||||
useEffect(() => {
|
||||
Events.tutorialStarted("matches");
|
||||
Events.tutorialStepViewed("matches", 0, STEPS[0].key);
|
||||
}, []);
|
||||
|
||||
// Track step changes
|
||||
useEffect(() => {
|
||||
if (currentStep > 0) {
|
||||
Events.tutorialStepViewed("matches", currentStep, STEPS[currentStep].key);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// Start animations
|
||||
pulseScale.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1.15, { duration: 800 }),
|
||||
withTiming(1, { duration: 800 })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
|
||||
iconBounce.value = withRepeat(
|
||||
withSequence(
|
||||
withDelay(200, withTiming(-8, { duration: 400 })),
|
||||
withTiming(0, { duration: 400 })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < STEPS.length - 1) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
Events.tutorialCompleted("matches");
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
Events.tutorialSkipped("matches", currentStep);
|
||||
onComplete();
|
||||
};
|
||||
|
||||
const pulseAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: pulseScale.value }],
|
||||
opacity: interpolate(pulseScale.value, [1, 1.15], [0.6, 0.2]),
|
||||
}));
|
||||
|
||||
const iconAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: iconBounce.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(300)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={styles.container}
|
||||
>
|
||||
<BlurView intensity={blur.heavy} tint="dark" style={styles.blur}>
|
||||
<View style={[styles.content, { paddingBottom: Math.max(40, insets.bottom + (Platform.OS === 'ios' ? 100 : 80)) }]}>
|
||||
{/* Skip button */}
|
||||
<Pressable style={styles.skipButton} onPress={handleSkip}>
|
||||
<Text style={styles.skipText}>Skip</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Central illustration area */}
|
||||
<View style={styles.illustrationArea}>
|
||||
<Animated.View
|
||||
key={currentStep}
|
||||
entering={FadeIn.duration(400)}
|
||||
>
|
||||
{/* Pulse ring behind icon */}
|
||||
<View style={styles.iconWrapper}>
|
||||
<Animated.View style={[styles.pulseRing, pulseAnimatedStyle]}>
|
||||
<LinearGradient
|
||||
colors={currentStepData.gradient}
|
||||
style={styles.pulseGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Main icon container */}
|
||||
<Animated.View style={iconAnimatedStyle}>
|
||||
<LinearGradient
|
||||
colors={currentStepData.gradient}
|
||||
style={styles.iconContainer}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons
|
||||
name={currentStepData.icon}
|
||||
size={56}
|
||||
color={colors.text}
|
||||
/>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Match type badges - show on first step */}
|
||||
{currentStep === 0 && (
|
||||
<View style={styles.badgesContainer}>
|
||||
<View style={styles.badgeRow}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.matchBadge}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
<Ionicons name="heart" size={16} color={colors.text} />
|
||||
<Text style={styles.badgeText}>YES + YES</Text>
|
||||
</LinearGradient>
|
||||
<View style={styles.badgeConnector} />
|
||||
<View style={[styles.matchBadge, styles.maybeBadge]}>
|
||||
<Ionicons name="heart-half" size={16} color={colors.primary} />
|
||||
<Text style={[styles.badgeText, styles.maybeBadgeText]}>YES + MAYBE</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Chat demo - show on second step */}
|
||||
{currentStep === 1 && (
|
||||
<View style={styles.chatDemo}>
|
||||
<View style={styles.chatBubbleLeft}>
|
||||
<Text style={styles.chatBubbleText}>I'd love to try this!</Text>
|
||||
</View>
|
||||
<View style={styles.chatBubbleRight}>
|
||||
<Text style={styles.chatBubbleText}>Me too! When?</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Photos demo - show on third step */}
|
||||
{currentStep === 2 && (
|
||||
<View style={styles.photosDemo}>
|
||||
<View style={styles.photoRow}>
|
||||
<View style={styles.photoPlaceholder}>
|
||||
<Ionicons name="image" size={24} color={colors.textSecondary} />
|
||||
</View>
|
||||
<View style={styles.photoPlaceholder}>
|
||||
<Ionicons name="image" size={24} color={colors.textSecondary} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.privacyBadge}>
|
||||
<Ionicons name="lock-closed" size={14} color={colors.success} />
|
||||
<Text style={styles.privacyBadgeText}>Private & Secure</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Text content */}
|
||||
<Animated.View
|
||||
key={`text-${currentStep}`}
|
||||
entering={FadeIn.delay(100).duration(300)}
|
||||
style={styles.textContainer}
|
||||
>
|
||||
<Text style={styles.title}>{currentStepData.title}</Text>
|
||||
<Text style={styles.description}>{currentStepData.description}</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Progress dots */}
|
||||
<View style={styles.dotsContainer}>
|
||||
{STEPS.map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.dot,
|
||||
index === currentStep && styles.dotActive,
|
||||
index < currentStep && styles.dotCompleted,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Next button */}
|
||||
<Pressable onPress={handleNext} style={({ pressed }) => [
|
||||
styles.nextButton,
|
||||
pressed && styles.nextButtonPressed
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.nextButtonGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
<Text style={styles.nextButtonText}>
|
||||
{currentStep < STEPS.length - 1 ? "Next" : "Got it!"}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={currentStep < STEPS.length - 1 ? "arrow-forward" : "checkmark"}
|
||||
size={20}
|
||||
color={colors.text}
|
||||
/>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
</View>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: 1000,
|
||||
},
|
||||
blur: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(13, 13, 26, 0.85)',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: 80,
|
||||
alignItems: "center",
|
||||
},
|
||||
skipButton: {
|
||||
position: "absolute",
|
||||
top: 60,
|
||||
right: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
skipText: {
|
||||
...typography.callout,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
illustrationArea: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
maxHeight: 350,
|
||||
},
|
||||
iconWrapper: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
pulseRing: {
|
||||
position: "absolute",
|
||||
width: 160,
|
||||
height: 160,
|
||||
borderRadius: 80,
|
||||
overflow: "hidden",
|
||||
},
|
||||
pulseGradient: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
iconContainer: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
...shadows.xl,
|
||||
},
|
||||
badgesContainer: {
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
badgeRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
matchBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
maybeBadge: {
|
||||
backgroundColor: colors.glass.background,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
badgeText: {
|
||||
...typography.caption1,
|
||||
color: colors.text,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
maybeBadgeText: {
|
||||
color: colors.primary,
|
||||
},
|
||||
badgeConnector: {
|
||||
width: 20,
|
||||
height: 2,
|
||||
backgroundColor: colors.glass.border,
|
||||
},
|
||||
chatDemo: {
|
||||
marginTop: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
width: "100%",
|
||||
maxWidth: 280,
|
||||
},
|
||||
chatBubbleLeft: {
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: colors.glass.background,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.lg,
|
||||
borderBottomLeftRadius: radius.xs,
|
||||
},
|
||||
chatBubbleRight: {
|
||||
alignSelf: "flex-end",
|
||||
backgroundColor: colors.primaryLight,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.lg,
|
||||
borderBottomRightRadius: radius.xs,
|
||||
},
|
||||
chatBubbleText: {
|
||||
...typography.callout,
|
||||
color: colors.text,
|
||||
},
|
||||
photosDemo: {
|
||||
marginTop: spacing.lg,
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
},
|
||||
photoRow: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
photoPlaceholder: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.glass.background,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
privacyBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.glass.background,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.success,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
privacyBadgeText: {
|
||||
...typography.caption1,
|
||||
color: colors.success,
|
||||
fontWeight: "600",
|
||||
},
|
||||
textContainer: {
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.xl,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
title: {
|
||||
...typography.title1,
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
description: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
textAlign: "center",
|
||||
lineHeight: 24,
|
||||
},
|
||||
dotsContainer: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.glass.border,
|
||||
},
|
||||
dotActive: {
|
||||
backgroundColor: colors.primary,
|
||||
width: 24,
|
||||
},
|
||||
dotCompleted: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
nextButton: {
|
||||
width: "100%",
|
||||
maxWidth: 280,
|
||||
borderRadius: radius.lg,
|
||||
overflow: "hidden",
|
||||
},
|
||||
nextButtonPressed: {
|
||||
opacity: 0.9,
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
nextButtonGradient: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
nextButtonText: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
},
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import Animated, {
|
||||
runOnJS,
|
||||
} from "react-native-reanimated";
|
||||
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme";
|
||||
import { Events } from "../lib/analytics";
|
||||
|
||||
interface Props {
|
||||
onComplete: () => void;
|
||||
@@ -110,6 +111,19 @@ export default function SwipeTutorial({ onComplete }: Props) {
|
||||
const currentStep_ = STEPS[currentStep];
|
||||
const isSwipeStep = currentStep_.isSwipe;
|
||||
|
||||
// Track tutorial started on mount
|
||||
useEffect(() => {
|
||||
Events.tutorialStarted("swipe");
|
||||
Events.tutorialStepViewed("swipe", 0, STEPS[0].key);
|
||||
}, []);
|
||||
|
||||
// Track step changes
|
||||
useEffect(() => {
|
||||
if (currentStep > 0) {
|
||||
Events.tutorialStepViewed("swipe", currentStep, STEPS[currentStep].key);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset animations for current step
|
||||
cardTranslateX.value = 0;
|
||||
@@ -217,11 +231,13 @@ export default function SwipeTutorial({ onComplete }: Props) {
|
||||
if (currentStep < STEPS.length - 1) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
Events.tutorialCompleted("swipe");
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
Events.tutorialSkipped("swipe", currentStep);
|
||||
onComplete();
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { StyleSheet, View, ViewStyle } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
interpolate,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
import { colors } from '../../theme';
|
||||
|
||||
interface ShimmerEffectProps {
|
||||
children: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
shimmerColor?: string;
|
||||
duration?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
|
||||
|
||||
export function ShimmerEffect({
|
||||
children,
|
||||
style,
|
||||
shimmerColor = colors.premium.gold,
|
||||
duration = 2500,
|
||||
enabled = true,
|
||||
}: ShimmerEffectProps) {
|
||||
const shimmerTranslate = useSharedValue(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
shimmerTranslate.value = withRepeat(
|
||||
withTiming(1, {
|
||||
duration,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
}),
|
||||
-1, // infinite repeat
|
||||
false // don't reverse
|
||||
);
|
||||
} else {
|
||||
shimmerTranslate.value = -1;
|
||||
}
|
||||
}, [enabled, duration]);
|
||||
|
||||
const shimmerStyle = useAnimatedStyle(() => {
|
||||
const translateX = interpolate(
|
||||
shimmerTranslate.value,
|
||||
[-1, 1],
|
||||
[-200, 200]
|
||||
);
|
||||
|
||||
return {
|
||||
transform: [
|
||||
{ translateX },
|
||||
{ rotate: '25deg' },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{children}
|
||||
{enabled && (
|
||||
<Animated.View style={[styles.shimmerContainer, shimmerStyle]}>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'transparent',
|
||||
`${shimmerColor}40`, // 25% opacity
|
||||
`${shimmerColor}60`, // 37% opacity
|
||||
`${shimmerColor}40`, // 25% opacity
|
||||
'transparent',
|
||||
]}
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
style={styles.shimmerGradient}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
shimmerContainer: {
|
||||
position: 'absolute',
|
||||
top: -50,
|
||||
bottom: -50,
|
||||
width: 100,
|
||||
left: '50%',
|
||||
marginLeft: -50,
|
||||
},
|
||||
shimmerGradient: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
export default ShimmerEffect;
|
||||
@@ -2,3 +2,4 @@ export { GradientBackground } from './GradientBackground';
|
||||
export { GlassCard } from './GlassCard';
|
||||
export { GlassButton } from './GlassButton';
|
||||
export { GlassInput } from './GlassInput';
|
||||
export { ShimmerEffect } from './ShimmerEffect';
|
||||
@@ -166,4 +166,14 @@ export const Events = {
|
||||
|
||||
// Invite events
|
||||
inviteCodeCopied: () => logEvent("invite_code_copied"),
|
||||
|
||||
// Tutorial events
|
||||
tutorialStarted: (tutorial: "swipe" | "matches") =>
|
||||
logEvent("tutorial_started", { tutorial }),
|
||||
tutorialStepViewed: (tutorial: "swipe" | "matches", step: number, stepName: string) =>
|
||||
logEvent("tutorial_step_viewed", { tutorial, step, step_name: stepName }),
|
||||
tutorialCompleted: (tutorial: "swipe" | "matches") =>
|
||||
logEvent("tutorial_completed", { tutorial }),
|
||||
tutorialSkipped: (tutorial: "swipe" | "matches", atStep: number) =>
|
||||
logEvent("tutorial_skipped", { tutorial, at_step: atStep }),
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const STORAGE_KEY = "matches_tutorial_seen";
|
||||
|
||||
async function getStorage(): Promise<boolean> {
|
||||
try {
|
||||
let data: string | null = null;
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
||||
data = window.localStorage.getItem(STORAGE_KEY);
|
||||
}
|
||||
} else {
|
||||
data = await SecureStore.getItemAsync(STORAGE_KEY);
|
||||
}
|
||||
|
||||
return data === "true";
|
||||
} catch (error) {
|
||||
console.error("Error reading matches tutorial state:", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function setStorage(seen: boolean): Promise<void> {
|
||||
try {
|
||||
const data = seen ? "true" : "false";
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
||||
window.localStorage.setItem(STORAGE_KEY, data);
|
||||
}
|
||||
} else {
|
||||
await SecureStore.setItemAsync(STORAGE_KEY, data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving matches tutorial state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasSeenMatchesTutorial(): Promise<boolean> {
|
||||
return getStorage();
|
||||
}
|
||||
|
||||
export async function markMatchesTutorialSeen(): Promise<void> {
|
||||
await setStorage(true);
|
||||
}
|
||||
|
||||
export async function resetMatchesTutorial(): Promise<void> {
|
||||
await setStorage(false);
|
||||
}
|
||||
@@ -28,8 +28,8 @@ export const colors = {
|
||||
|
||||
// Text
|
||||
text: '#ffffff',
|
||||
textSecondary: 'rgba(255, 255, 255, 0.6)',
|
||||
textTertiary: 'rgba(255, 255, 255, 0.4)',
|
||||
textSecondary: 'rgba(255, 255, 255, 0.7)', // Bumped from 0.6 for better contrast
|
||||
textTertiary: 'rgba(255, 255, 255, 0.5)', // Bumped from 0.4 for accessibility
|
||||
|
||||
// Semantic
|
||||
success: '#2ECC71',
|
||||
@@ -38,6 +38,17 @@ export const colors = {
|
||||
warningLight: 'rgba(243, 156, 18, 0.15)',
|
||||
error: '#E74C3C',
|
||||
errorLight: 'rgba(231, 76, 60, 0.15)',
|
||||
|
||||
// Premium/Luxury
|
||||
premium: {
|
||||
gold: '#D4AF37',
|
||||
goldLight: 'rgba(212, 175, 55, 0.15)',
|
||||
goldGlow: 'rgba(212, 175, 55, 0.3)',
|
||||
champagne: '#F7E7CE',
|
||||
champagneLight: 'rgba(247, 231, 206, 0.1)',
|
||||
rose: '#E8A4AE', // Lightened for accessibility (was #B76E79, now ~5.5:1 contrast)
|
||||
roseLight: 'rgba(232, 164, 174, 0.15)',
|
||||
},
|
||||
};
|
||||
|
||||
export const gradients = {
|
||||
@@ -51,6 +62,18 @@ export const gradients = {
|
||||
success: ['#2ECC71', '#27ae60'],
|
||||
warning: ['#F39C12', '#e67e22'],
|
||||
error: ['#E74C3C', '#c0392b'],
|
||||
// Premium/Boutique gradients
|
||||
premiumGold: ['#D4AF37', '#B8860B'],
|
||||
premiumRose: ['#B76E79', '#8B4557'],
|
||||
silkLight: ['rgba(255, 255, 255, 0.08)', 'rgba(255, 255, 255, 0.02)'],
|
||||
laceTint: ['rgba(233, 69, 96, 0.15)', 'rgba(155, 89, 182, 0.15)'],
|
||||
// Boutique pack card backgrounds
|
||||
boutiqueRose: ['rgba(233, 69, 96, 0.4)', 'rgba(155, 89, 182, 0.4)'],
|
||||
boutiquePurple: ['rgba(155, 89, 182, 0.4)', 'rgba(233, 69, 96, 0.4)'],
|
||||
boutiqueDusty: ['rgba(183, 110, 121, 0.35)', 'rgba(139, 69, 87, 0.35)'],
|
||||
boutiqueGold: ['rgba(212, 175, 55, 0.25)', 'rgba(184, 134, 11, 0.25)'],
|
||||
boutiqueMidnight: ['rgba(22, 33, 62, 0.8)', 'rgba(13, 13, 26, 0.8)'],
|
||||
boutiqueAmethyst: ['rgba(142, 68, 173, 0.4)', 'rgba(44, 62, 80, 0.4)'],
|
||||
};
|
||||
|
||||
export const spacing = {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Supabase - Agent Guidelines
|
||||
|
||||
**STRICT RULES - DO NOT VIOLATE**
|
||||
|
||||
## Architecture
|
||||
|
||||
This contains Supabase configuration:
|
||||
- **Migrations**: SQL migrations in `migrations/`
|
||||
- **Edge Functions**: Deno functions in `functions/`
|
||||
- **Email Templates**: Auth email templates in `email-templates/`
|
||||
- **Config**: Local development config in `config.toml`
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
functions/
|
||||
delete-relationship/ # Delete couple relationship
|
||||
manage-couple/ # Join/leave couple
|
||||
revenuecat-webhook/ # Subscription webhook handler
|
||||
send-message-notification/ # Push notification for messages
|
||||
send-notification/ # Push notification for matches
|
||||
submit-response/ # Save response, check matches
|
||||
sync-subscription/ # Sync RevenueCat status
|
||||
migrations/ # SQL migrations
|
||||
email-templates/ # Auth email templates
|
||||
config.toml # Local dev configuration
|
||||
```
|
||||
|
||||
## Critical Rules
|
||||
|
||||
### Edge Function Deployment
|
||||
|
||||
**ALWAYS use `verify_jwt: false`** when deploying via MCP tools.
|
||||
|
||||
Supabase's built-in JWT verification causes 401 errors. Handle auth manually:
|
||||
|
||||
```typescript
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(
|
||||
authHeader.replace("Bearer ", "")
|
||||
);
|
||||
```
|
||||
|
||||
### Edge Function Pattern
|
||||
|
||||
All edge functions MUST follow this structure:
|
||||
|
||||
```typescript
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||
);
|
||||
|
||||
// Manual auth verification
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing authorization header" }),
|
||||
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(
|
||||
authHeader.replace("Bearer ", "")
|
||||
);
|
||||
|
||||
// ... function logic
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Internal server error" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Function Auth Methods
|
||||
|
||||
| Function | Auth Method |
|
||||
|----------|-------------|
|
||||
| `manage-couple` | Manual via getUser() |
|
||||
| `submit-response` | Manual via getUser() |
|
||||
| `sync-subscription` | Manual via getUser() |
|
||||
| `delete-relationship` | Manual via getUser() |
|
||||
| `revenuecat-webhook` | Webhook signature validation |
|
||||
| `send-notification` | Internal trigger (no auth) |
|
||||
| `send-message-notification` | Internal trigger (no auth) |
|
||||
|
||||
### Migration Rules
|
||||
|
||||
- Use `apply_migration` MCP tool for DDL operations
|
||||
- Never hardcode generated IDs in data migrations
|
||||
- Always verify target environment (prod vs non-prod) before running
|
||||
|
||||
### Database Patterns
|
||||
|
||||
- Use UPSERT with `onConflict` for responses: `onConflict: "user_id,question_id"`
|
||||
- Use UPSERT for matches: `onConflict: "couple_id,question_id"`
|
||||
- RLS policies enforce couple-level data isolation
|
||||
|
||||
### Profile Creation
|
||||
|
||||
The `on_auth_user_created` trigger automatically creates a profile. Never assume a profile exists without checking - check for null before operations.
|
||||
|
||||
## MCP Environments
|
||||
|
||||
| Environment | MCP Server |
|
||||
|-------------|------------|
|
||||
| Production | `mcp__sauci-prod__*` |
|
||||
| Non-Production | `mcp__sauci-non-prod__*` |
|
||||
|
||||
**ALWAYS verify environment before destructive operations.**
|
||||
|
||||
## Forbidden Actions
|
||||
|
||||
1. **DO NOT** use `verify_jwt: true` when deploying functions via MCP
|
||||
2. **DO NOT** skip CORS headers in responses
|
||||
3. **DO NOT** use service role key on client-side
|
||||
4. **DO NOT** hardcode IDs in migrations
|
||||
5. **DO NOT** assume profiles exist - always check for null
|
||||
6. **DO NOT** run migrations on production without explicit confirmation
|
||||
7. **DO NOT** expose internal error details in responses
|
||||
@@ -1028,6 +1028,7 @@ CREATE POLICY "Users can view own feedback screenshots"
|
||||
);
|
||||
|
||||
-- Chat media policies
|
||||
-- IMPORTANT: Use storage.objects.name (not just "name") to avoid ambiguity with profiles.name
|
||||
CREATE POLICY "Users can view chat media in their matches"
|
||||
ON storage.objects FOR SELECT TO authenticated
|
||||
USING (
|
||||
@@ -1035,7 +1036,7 @@ CREATE POLICY "Users can view chat media in their matches"
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM matches m
|
||||
JOIN profiles p ON p.couple_id = m.couple_id
|
||||
WHERE (storage.foldername(name))[1] = m.id::text
|
||||
WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
|
||||
AND p.id = auth.uid()
|
||||
)
|
||||
);
|
||||
@@ -1047,7 +1048,7 @@ CREATE POLICY "Users can upload chat media to their matches"
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM matches m
|
||||
JOIN profiles p ON p.couple_id = m.couple_id
|
||||
WHERE (storage.foldername(name))[1] = m.id::text
|
||||
WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
|
||||
AND p.id = auth.uid()
|
||||
)
|
||||
);
|
||||
@@ -1059,7 +1060,7 @@ CREATE POLICY "Users can delete their own chat media"
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM matches m
|
||||
JOIN profiles p ON p.couple_id = m.couple_id
|
||||
WHERE (storage.foldername(name))[1] = m.id::text
|
||||
WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
|
||||
AND p.id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Fix storage policies that incorrectly reference p.name instead of storage.objects.name
|
||||
-- Bug: Using unqualified "name" in a subquery with profiles table causes Postgres to resolve
|
||||
-- it to profiles.name instead of storage.objects.name
|
||||
-- Fix: Explicitly qualify as storage.objects.name to reference the storage object's file path
|
||||
|
||||
-- Drop existing broken policies
|
||||
DROP POLICY IF EXISTS "Users can upload chat media to their matches" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "Users can view chat media in their matches" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "Users can delete their own chat media" ON storage.objects;
|
||||
|
||||
-- Recreate with explicit table qualification to avoid column name ambiguity
|
||||
CREATE POLICY "Users can upload chat media to their matches"
|
||||
ON storage.objects FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
bucket_id = 'chat-media'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM matches m
|
||||
JOIN profiles p ON p.couple_id = m.couple_id
|
||||
WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
|
||||
AND p.id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Users can view chat media in their matches"
|
||||
ON storage.objects FOR SELECT TO authenticated
|
||||
USING (
|
||||
bucket_id = 'chat-media'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM matches m
|
||||
JOIN profiles p ON p.couple_id = m.couple_id
|
||||
WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
|
||||
AND p.id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Users can delete their own chat media"
|
||||
ON storage.objects FOR DELETE TO authenticated
|
||||
USING (
|
||||
bucket_id = 'chat-media'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM matches m
|
||||
JOIN profiles p ON p.couple_id = m.couple_id
|
||||
WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
|
||||
AND p.id = auth.uid()
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,86 @@
|
||||
# Web App - Agent Guidelines
|
||||
|
||||
**STRICT RULES - DO NOT VIOLATE**
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a Next.js 14+ marketing/landing website using:
|
||||
- **Framework**: Next.js with App Router
|
||||
- **Styling**: Tailwind CSS
|
||||
- **Analytics**: PostHog
|
||||
- **Routing**: File-based routing in `app/` directory
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
app/
|
||||
layout.tsx # Root layout with PostHog provider
|
||||
page.tsx # Home/landing page
|
||||
globals.css # Global styles
|
||||
providers.tsx # PostHog provider wrapper
|
||||
PostHogPageView.tsx # Page view tracking component
|
||||
privacy/ # Privacy policy
|
||||
terms/ # Terms of service
|
||||
redeem/ # Code redemption page
|
||||
components/ # Reusable components
|
||||
public/ # Static assets (images, fonts)
|
||||
```
|
||||
|
||||
## Critical Rules
|
||||
|
||||
### App Router
|
||||
|
||||
This uses Next.js App Router (not Pages Router):
|
||||
- Server Components by default
|
||||
- Use `'use client'` directive for client components
|
||||
- Metadata exported from layout.tsx and page.tsx
|
||||
|
||||
### PostHog Integration
|
||||
|
||||
PostHog is configured at root level:
|
||||
- Provider in `providers.tsx`
|
||||
- Page view tracking in `PostHogPageView.tsx`
|
||||
- Wrap all pages in `<PostHogProvider>`
|
||||
|
||||
### SEO & Metadata
|
||||
|
||||
Metadata is defined in `layout.tsx`:
|
||||
```typescript
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sauci - Ignite Your Connection',
|
||||
description: '...',
|
||||
openGraph: { ... },
|
||||
twitter: { ... },
|
||||
};
|
||||
```
|
||||
|
||||
Update metadata when adding new pages.
|
||||
|
||||
### Styling
|
||||
|
||||
- Use Tailwind CSS classes
|
||||
- Global styles in `globals.css`
|
||||
- Follow mobile-first responsive design
|
||||
|
||||
### Static Pages
|
||||
|
||||
Privacy and Terms pages are static content:
|
||||
- `/privacy` - Privacy policy
|
||||
- `/terms` - Terms of service
|
||||
|
||||
Keep these updated with legal requirements.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Required environment variables:
|
||||
- `NEXT_PUBLIC_POSTHOG_KEY` - PostHog API key
|
||||
- `NEXT_PUBLIC_POSTHOG_HOST` - PostHog host
|
||||
|
||||
## Forbidden Actions
|
||||
|
||||
1. **DO NOT** remove PostHog tracking without approval
|
||||
2. **DO NOT** use Pages Router patterns (use App Router)
|
||||
3. **DO NOT** add server-side API routes that expose sensitive data
|
||||
4. **DO NOT** forget to update metadata for new pages
|
||||
5. **DO NOT** skip mobile responsiveness
|
||||
6. **DO NOT** commit .env files with real API keys
|
||||
Reference in new issue
Block a user