This commit is contained in:
Jamie Brunton committed 2025-12-31 10:40:17 +00:00
1 parent 71667d6104
commit f46f0d59df
24 files changed
+2558 -427

No files matched your search

+84
View File
@@ -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
+124 -1
View File
@@ -34,7 +34,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } 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'; import { format } from 'date-fns';
interface Profile { interface Profile {
@@ -77,6 +77,18 @@ interface Match {
message_count?: number; 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 = { const answerIcons = {
yes: <ThumbsUp className="h-4 w-4 text-green-500" />, yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
no: <ThumbsDown className="h-4 w-4 text-red-500" />, no: <ThumbsDown className="h-4 w-4 text-red-500" />,
@@ -89,12 +101,22 @@ const matchTypeLabels = {
maybe_maybe: 'Both Maybe', 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() { export function UserDetailPage() {
const { userId } = useParams<{ userId: string }>(); const { userId } = useParams<{ userId: string }>();
const [profile, setProfile] = useState<Profile | null>(null); const [profile, setProfile] = useState<Profile | null>(null);
const [partner, setPartner] = useState<Partner | null>(null); const [partner, setPartner] = useState<Partner | null>(null);
const [responses, setResponses] = useState<Response[]>([]); const [responses, setResponses] = useState<Response[]>([]);
const [matches, setMatches] = useState<Match[]>([]); const [matches, setMatches] = useState<Match[]>([]);
const [media, setMedia] = useState<MediaFile[]>([]);
const [totalStorage, setTotalStorage] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [upgradeOpen, setUpgradeOpen] = useState(false); const [upgradeOpen, setUpgradeOpen] = useState(false);
@@ -239,6 +261,31 @@ export function UserDetailPage() {
})) as unknown as Match[] })) 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) { } catch (error) {
console.error('Failed to load user data:', error); console.error('Failed to load user data:', error);
} finally { } finally {
@@ -392,6 +439,9 @@ export function UserDetailPage() {
<TabsTrigger value="matches" disabled={!profile.couple_id}> <TabsTrigger value="matches" disabled={!profile.couple_id}>
Matches ({matches.length}) Matches ({matches.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="media">
Media ({media.length})
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="responses" className="mt-4"> <TabsContent value="responses" className="mt-4">
@@ -479,6 +529,79 @@ export function UserDetailPage() {
</div> </div>
)} )}
</TabsContent> </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> </Tabs>
</div> </div>
); );
+45 -5
View File
@@ -14,7 +14,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table'; } 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'; import { format } from 'date-fns';
interface Profile { interface Profile {
@@ -25,6 +25,15 @@ interface Profile {
is_premium: boolean | null; is_premium: boolean | null;
couple_id: string | null; couple_id: string | null;
created_at: 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() { export function UsersPage() {
@@ -37,14 +46,34 @@ export function UsersPage() {
const fetchProfiles = async () => { const fetchProfiles = async () => {
setLoading(true); setLoading(true);
try { try {
const { data, error } = await supabase // Fetch profiles
const { data: profilesData, error } = await supabase
.from('profiles') .from('profiles')
.select('*') .select('*')
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
if (error) throw error; 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) { } catch (error) {
console.error('Failed to load profiles:', error); console.error('Failed to load profiles:', error);
} finally { } finally {
@@ -114,6 +143,7 @@ export function UsersPage() {
<TableHead>Email</TableHead> <TableHead>Email</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Couple</TableHead> <TableHead>Couple</TableHead>
<TableHead>Storage</TableHead>
<TableHead>Joined</TableHead> <TableHead>Joined</TableHead>
<TableHead className="w-12"></TableHead> <TableHead className="w-12"></TableHead>
</TableRow> </TableRow>
@@ -121,7 +151,7 @@ export function UsersPage() {
<TableBody> <TableBody>
{filteredProfiles.length === 0 ? ( {filteredProfiles.length === 0 ? (
<TableRow> <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.'} {search ? 'No users found matching your search.' : 'No users yet.'}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -166,6 +196,16 @@ export function UsersPage() {
<span className="text-muted-foreground text-sm">—</span> <span className="text-muted-foreground text-sm">—</span>
)} )}
</TableCell> </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"> <TableCell className="text-muted-foreground text-sm">
{profile.created_at {profile.created_at
? format(new Date(profile.created_at), 'MMM d, yyyy') ? format(new Date(profile.created_at), 'MMM d, yyyy')
+102
View File
@@ -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
+33 -3
View File
@@ -1,6 +1,14 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, useWindowDimensions } from "react-native"; 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 { LinearGradient } from "expo-linear-gradient";
import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store"; import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
@@ -25,6 +33,28 @@ export default function HomeScreen() {
const isWideScreen = width > MAX_CONTENT_WIDTH; const isWideScreen = width > MAX_CONTENT_WIDTH;
const enabledPacksCount = packs.length; 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 ( return (
<GradientBackground> <GradientBackground>
<ScrollView <ScrollView
@@ -191,9 +221,9 @@ export default function HomeScreen() {
Swipe through questions and discover what you both enjoy Swipe through questions and discover what you both enjoy
</Text> </Text>
</View> </View>
<View style={styles.ctaIconContainer}> <Animated.View style={[styles.ctaIconContainer, pulseAnimatedStyle]}>
<Ionicons name="flame" size={24} color={colors.text} /> <Ionicons name="flame" size={24} color={colors.text} />
</View> </Animated.View>
</LinearGradient> </LinearGradient>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
+29 -1
View File
@@ -1,6 +1,6 @@
import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native"; import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native";
import { useMatchStore, useAuthStore } from "../../src/store"; import { useMatchStore, useAuthStore } from "../../src/store";
import { useEffect, useCallback } from "react"; import { useEffect, useCallback, useState } from "react";
import { useFocusEffect } from "expo-router"; import { useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
@@ -8,6 +8,8 @@ import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated"; import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated";
import { GradientBackground, GlassCard } from "../../src/components/ui"; import { GradientBackground, GlassCard } from "../../src/components/ui";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme"; import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
import MatchesTutorial from "../../src/components/MatchesTutorial";
import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen";
const MAX_CONTENT_WIDTH = 500; const MAX_CONTENT_WIDTH = 500;
@@ -17,6 +19,27 @@ export default function MatchesScreen() {
const router = useRouter(); const router = useRouter();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH; 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( useFocusEffect(
useCallback(() => { useCallback(() => {
@@ -187,6 +210,11 @@ export default function MatchesScreen() {
} }
/> />
</View> </View>
{/* Matches Tutorial Overlay */}
{showTutorial && (
<MatchesTutorial onComplete={handleTutorialComplete} />
)}
</GradientBackground> </GradientBackground>
); );
} }
-1
View File
@@ -313,7 +313,6 @@ export default function OnboardingScreen() {
</View> </View>
<GlassCard style={styles.card}> <GlassCard style={styles.card}>
<Text style={styles.label}>I'm using Sauci to...</Text>
<View style={styles.purposeList}> <View style={styles.purposeList}>
{PURPOSE_OPTIONS.map((option) => ( {PURPOSE_OPTIONS.map((option) => (
<Pressable <Pressable
+136 -266
View File
@@ -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 { usePacksStore, useAuthStore, useSubscriptionStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons"; 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 { useEffect, useMemo, useState, useCallback } from "react";
import Animated, { FadeInDown, FadeInRight, FadeIn, FadeOut, Layout } from "react-native-reanimated"; import Animated, { FadeInDown, FadeIn, Layout } from "react-native-reanimated";
import { GradientBackground, GlassCard } from "../../src/components/ui"; import { GradientBackground } from "../../src/components/ui";
import { Paywall } from "../../src/components/Paywall"; import { Paywall } from "../../src/components/Paywall";
import { PackTeaser } from "../../src/components/PackTeaser"; import { PackTeaser } from "../../src/components/PackTeaser";
import { colors, spacing, typography, radius } from "../../src/theme"; import { CategoryTabs, FilterType } from "../../src/components/CategoryTabs";
import type { QuestionPack, Category } from "../../src/types"; import { BoutiquePackCard } from "../../src/components/BoutiquePackCard";
import { colors, spacing, typography } from "../../src/theme";
interface CategoryWithPacks extends Category { import type { QuestionPack } from "../../src/types";
packs: QuestionPack[];
}
export default function PacksScreen() { export default function PacksScreen() {
const { category } = useLocalSearchParams<{ category?: string }>();
const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore(); const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
const { user, partner } = useAuthStore(); const { user, partner } = useAuthStore();
const { subscription } = useSubscriptionStore(); const { subscription } = useSubscriptionStore();
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set()); const [selectedFilter, setSelectedFilter] = useState<FilterType>('all');
const [showPaywall, setShowPaywall] = useState(false); const [showPaywall, setShowPaywall] = useState(false);
const [showTeaser, setShowTeaser] = useState(false); const [showTeaser, setShowTeaser] = useState(false);
const [teaserPack, setTeaserPack] = useState<QuestionPack | null>(null); const [teaserPack, setTeaserPack] = useState<QuestionPack | null>(null);
@@ -32,6 +31,47 @@ export default function PacksScreen() {
fetchPacks(); 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 // Handle toggling a pack - show teaser if premium pack without access
const handleTogglePack = useCallback(async (pack: QuestionPack) => { const handleTogglePack = useCallback(async (pack: QuestionPack) => {
if (pack.is_premium && !hasPremiumAccess) { if (pack.is_premium && !hasPremiumAccess) {
@@ -52,6 +92,17 @@ export default function PacksScreen() {
} }
}, [hasPremiumAccess, togglePack]); }, [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 // Handle teaser unlock button - show paywall
const handleTeaserUnlock = useCallback(() => { const handleTeaserUnlock = useCallback(() => {
setShowTeaser(false); setShowTeaser(false);
@@ -68,155 +119,10 @@ export default function PacksScreen() {
const enabledPackCount = enabledPacks.length; const enabledPackCount = enabledPacks.length;
const totalQuestions = enabledPacks.reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0); 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 ( return (
<GradientBackground> <GradientBackground>
<View style={styles.container}> <View style={styles.container}>
{/* Header */}
<Animated.View <Animated.View
entering={FadeInDown.duration(400)} entering={FadeInDown.duration(400)}
style={styles.header} style={styles.header}
@@ -227,11 +133,18 @@ export default function PacksScreen() {
</Text> </Text>
</Animated.View> </Animated.View>
<FlatList {/* Category Tabs */}
data={categoriesWithPacks} <Animated.View entering={FadeInDown.delay(100).duration(400)}>
renderItem={renderCategory} <CategoryTabs
keyExtractor={(item) => item.id} categories={categories}
contentContainerStyle={styles.list} selectedFilter={selectedFilter}
onSelectFilter={setSelectedFilter}
enabledCount={enabledPackIds.length}
/>
</Animated.View>
{/* Masonry Grid */}
<ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
@@ -241,7 +154,9 @@ export default function PacksScreen() {
colors={[colors.primary]} colors={[colors.primary]}
/> />
} }
ListEmptyComponent={ contentContainerStyle={styles.scrollContent}
>
{filteredPacks.length === 0 ? (
<Animated.View <Animated.View
entering={FadeInDown.delay(200).duration(400)} entering={FadeInDown.delay(200).duration(400)}
style={styles.emptyContainer} style={styles.emptyContainer}
@@ -251,12 +166,59 @@ export default function PacksScreen() {
</View> </View>
<Text style={styles.emptyTitle}>No packs available</Text> <Text style={styles.emptyTitle}>No packs available</Text>
<Text style={styles.emptyText}> <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> </Text>
</Animated.View> </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 && ( {teaserPack && (
<PackTeaser <PackTeaser
visible={showTeaser} visible={showTeaser}
@@ -288,7 +250,7 @@ const styles = StyleSheet.create({
header: { header: {
paddingTop: 60, paddingTop: 60,
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingBottom: spacing.md, paddingBottom: spacing.sm,
}, },
title: { title: {
...typography.title1, ...typography.title1,
@@ -299,114 +261,22 @@ const styles = StyleSheet.create({
color: colors.textSecondary, color: colors.textSecondary,
marginTop: spacing.xs, marginTop: spacing.xs,
}, },
list: { scrollContent: {
padding: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 120 : 100, paddingBottom: Platform.OS === 'ios' ? 120 : 100,
}, },
categoryCard: { masonryContainer: {
marginBottom: spacing.md, flexDirection: 'row',
paddingHorizontal: spacing.md,
gap: spacing.md,
}, },
categoryHeader: { column: {
flexDirection: "row",
alignItems: "center",
},
categoryIcon: {
fontSize: 32,
marginRight: spacing.md,
},
categoryInfo: {
flex: 1, flex: 1,
}, gap: spacing.md,
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",
}, },
emptyContainer: { emptyContainer: {
alignItems: "center", alignItems: "center",
paddingVertical: spacing.xxl, paddingVertical: spacing.xxl,
paddingHorizontal: spacing.lg,
}, },
emptyIconContainer: { emptyIconContainer: {
width: 80, width: 80,
+55
View File
@@ -6,6 +6,7 @@ import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown } from "react-native-reanimated"; import Animated, { FadeInDown } from "react-native-reanimated";
import { supabase } from "../../src/lib/supabase"; import { supabase } from "../../src/lib/supabase";
import { resetSwipeTutorial } from "../../src/lib/swipeTutorialSeen"; import { resetSwipeTutorial } from "../../src/lib/swipeTutorialSeen";
import { resetMatchesTutorial } from "../../src/lib/matchesTutorialSeen";
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system'; import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer'; import { decode } from 'base64-arraybuffer';
@@ -821,6 +822,60 @@ export default function ProfileScreen() {
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} /> <Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</View> </View>
</TouchableOpacity> </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> </GlassCard>
</Animated.View> </Animated.View>
)} )}
+341 -145
View File
@@ -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 { useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LinearGradient } from "expo-linear-gradient";
import { BlurView } from "expo-blur";
import { supabase } from "../../src/lib/supabase"; 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 { 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"; import { Question } from "../../src/types";
type TooltipType = 'initiator' | 'coupleType' | null; 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() { export default function PackDetailsScreen() {
const { id } = useLocalSearchParams(); const { id } = useLocalSearchParams();
const router = useRouter(); const router = useRouter();
const { packs, fetchPacks } = usePacksStore(); const { packs, fetchPacks } = usePacksStore();
const { subscription } = useSubscriptionStore();
const { user, partner } = useAuthStore();
const [questions, setQuestions] = useState<Question[]>([]); const [questions, setQuestions] = useState<Question[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [activeTooltip, setActiveTooltip] = useState<TooltipType>(null); const [activeTooltip, setActiveTooltip] = useState<TooltipType>(null);
const pack = packs.find((p) => p.id === id); const pack = packs.find((p) => p.id === id);
const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser;
const isPremium = pack?.is_premium && hasPremiumAccess;
// Get category for breadcrumb
const { categories } = usePacksStore();
const category = categories.find(c => c.id === pack?.category_id);
useEffect(() => { useEffect(() => {
if (packs.length === 0) { if (packs.length === 0) {
@@ -53,53 +74,75 @@ export default function PackDetailsScreen() {
} }
}; };
const renderItem = ({ item }: { item: Question }) => ( const useBlur = Platform.OS === 'ios';
<View style={styles.card}>
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> <Text style={styles.questionText}>{item.text}</Text>
{item.partner_text && ( {item.partner_text && (
<Text style={styles.partnerText}>Partner role: {item.partner_text}</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}> <View style={styles.footerContainer}>
{/* Intensity */}
<View style={styles.intensityContainer}> <View style={styles.intensityContainer}>
{[...Array(5)].map((_, index) => ( {[...Array(5)].map((_, i) => (
<Ionicons <Ionicons
key={index} key={i}
name="flame" name="flame"
size={12} size={14}
color={index < item.intensity ? "#e94560" : "#333"} color={i < item.intensity ? colors.primary : colors.textTertiary}
style={{ marginRight: 2 }} style={{ marginRight: 2 }}
/> />
))} ))}
</View> </View>
<View style={styles.badgesContainer}>
{/* Initiator indicator */} {/* Initiator indicator */}
{item.target_user_genders && item.target_user_genders.length > 0 ? ( {item.target_user_genders && item.target_user_genders.length > 0 && (
<View style={styles.targetsContainer}>
<Pressable <Pressable
style={[styles.targetBadge, styles.initiatorBadge]} style={styles.targetBadge}
onPress={() => setActiveTooltip('initiator')} onPress={() => setActiveTooltip('initiator')}
> >
<Ionicons name="arrow-forward" size={12} color="#f39c12" style={{ marginRight: 4 }} /> <Ionicons name="arrow-forward" size={12} color={colors.premium.gold} style={{ marginRight: 4 }} />
{item.target_user_genders.map((g, i) => ( {item.target_user_genders.map((g, i) => (
<Ionicons <Ionicons
key={g} key={g}
name={g === 'male' ? 'male' : g === 'female' ? 'female' : 'person'} name={g === 'male' ? 'male' : g === 'female' ? 'female' : 'person'}
size={14} size={14}
color="#f39c12" color={colors.premium.gold}
style={i > 0 ? { marginLeft: 4 } : {}} style={i > 0 ? { marginLeft: 4 } : {}}
/> />
))} ))}
<Ionicons name="help-circle-outline" size={14} color="#f39c12" style={{ marginLeft: 6 }} /> <Ionicons name="help-circle-outline" size={12} color={colors.premium.gold} style={{ marginLeft: 4 }} />
</Pressable> </Pressable>
</View> )}
) : null}
{/* Couple type indicator */} {/* Couple type indicator */}
{item.allowed_couple_genders && item.allowed_couple_genders.length > 0 ? (
<Pressable <Pressable
style={styles.targetsContainer} style={styles.targetBadge}
onPress={() => setActiveTooltip('coupleType')} onPress={() => setActiveTooltip('coupleType')}
> >
{item.allowed_couple_genders.map((g) => { {item.allowed_couple_genders && item.allowed_couple_genders.length > 0 ? (
const icons = []; item.allowed_couple_genders.map((g) => {
const icons: ('male' | 'female')[] = [];
if (g === 'male+male') { if (g === 'male+male') {
icons.push('male', 'male'); icons.push('male', 'male');
} else if (g === 'female+female') { } else if (g === 'female+female') {
@@ -109,72 +152,145 @@ export default function PackDetailsScreen() {
} }
return ( return (
<View key={g} style={styles.targetBadge}> <View key={g} style={styles.coupleIconGroup}>
{icons.map((icon, i) => ( {icons.map((icon, i) => (
<Ionicons <Ionicons
key={i} key={i}
name={icon as any} name={icon}
size={14} size={14}
color="#ccc" color={colors.textSecondary}
style={i > 0 ? { marginLeft: 4 } : {}} style={i > 0 ? { marginLeft: 2 } : {}}
/> />
))} ))}
</View> </View>
); );
})} })
<Ionicons name="help-circle-outline" size={14} color="#ccc" style={{ marginLeft: 6 }} />
</Pressable>
) : ( ) : (
<Pressable <Ionicons name="people" size={14} color={colors.textSecondary} />
style={styles.targetsContainer}
onPress={() => setActiveTooltip('coupleType')}
>
<View style={styles.targetBadge}>
<Ionicons name="people" size={14} color="#ccc" />
</View>
<Ionicons name="help-circle-outline" size={14} color="#ccc" style={{ marginLeft: 6 }} />
</Pressable>
)} )}
<Ionicons name="help-circle-outline" size={12} color={colors.textTertiary} style={{ marginLeft: 4 }} />
</Pressable>
</View> </View>
</View> </View>
</View>
</GlassCard>
</Animated.View>
); );
};
if (isLoading) { if (isLoading) {
return ( return (
<GradientBackground>
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#e94560" /> <ActivityIndicator size="large" color={colors.primary} />
</View> </View>
</GradientBackground>
); );
} }
return ( const heroContent = (
<View style={styles.container}> <View style={styles.heroInner}>
<View style={styles.header}> {/* 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}> <TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color="#fff" /> <View style={styles.backButtonInner}>
<Ionicons name="arrow-back" size={22} color={colors.text} />
</View>
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.title}>{pack?.name || "Pack Details"}</Text>
<View style={{ width: 24 }} /> {/* Pack info */}
<View style={styles.heroContent}>
<View style={[
styles.iconContainer,
isPremium && styles.iconContainerPremium
]}>
<Text style={styles.emoji}>{pack?.icon || "📦"}</Text>
</View> </View>
{pack && ( {pack?.is_premium && (
<View style={styles.packInfo}> <View style={styles.proBadge}>
<View style={styles.iconContainer}> <Ionicons name="star" size={12} color={colors.text} />
<Text style={styles.emoji}>{pack.icon || "📦"}</Text> <Text style={styles.proBadgeText}>PRO</Text>
</View>
<Text style={styles.description}>{pack.description}</Text>
</View> </View>
)} )}
<View style={styles.sectionHeader}> {/* Category breadcrumb - tappable to filter by category */}
<Text style={styles.sectionTitle}>Questions ({questions.length})</Text> {category && (
</View> <TouchableOpacity
style={styles.breadcrumb}
onPress={() => router.replace(`/(app)/packs?category=${category.id}`)}
activeOpacity={0.7}
>
<Text style={styles.breadcrumbText}>{category.icon} {category.name}</Text>
<Ionicons name="chevron-forward" size={12} color={colors.textTertiary} />
</TouchableOpacity>
)}
<Text style={styles.title}>{pack?.name || "Pack Details"}</Text>
<Text style={styles.description}>{pack?.description}</Text>
<View style={styles.statsBadge}>
<Text style={styles.statsText}>{questions.length} questions</Text>
</View>
</View>
</View>
);
return (
<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>
{/* Questions List */}
<FlatList <FlatList
data={questions} data={questions}
renderItem={renderItem} renderItem={renderItem}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
contentContainerStyle={styles.list} 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>
}
/> />
{/* Tooltip Modal */} {/* Tooltip Modal */}
@@ -188,7 +304,11 @@ export default function PackDetailsScreen() {
style={styles.modalOverlay} style={styles.modalOverlay}
onPress={() => setActiveTooltip(null)} onPress={() => setActiveTooltip(null)}
> >
<View style={styles.tooltipContainer}> <GlassCard style={styles.tooltipContainer}>
<LinearGradient
colors={gradients.laceTint as [string, string]}
style={StyleSheet.absoluteFill}
/>
<Text style={styles.tooltipTitle}> <Text style={styles.tooltipTitle}>
{activeTooltip && TOOLTIP_CONTENT[activeTooltip].title} {activeTooltip && TOOLTIP_CONTENT[activeTooltip].title}
</Text> </Text>
@@ -198,166 +318,242 @@ export default function PackDetailsScreen() {
<TouchableOpacity <TouchableOpacity
style={styles.tooltipButton} style={styles.tooltipButton}
onPress={() => setActiveTooltip(null)} 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> <Text style={styles.tooltipButtonText}>Got it</Text>
</LinearGradient>
</TouchableOpacity> </TouchableOpacity>
</View> </GlassCard>
</Pressable> </Pressable>
</Modal> </Modal>
</View> </View>
</GradientBackground>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: "#1a1a2e",
}, },
loadingContainer: { loadingContainer: {
flex: 1, flex: 1,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
backgroundColor: "#1a1a2e",
}, },
header: { heroContainer: {
paddingTop: 60, borderBottomLeftRadius: radius.xl,
paddingHorizontal: 24, borderBottomRightRadius: radius.xl,
paddingBottom: 24, overflow: 'hidden',
flexDirection: "row", ...shadows.lg,
alignItems: "center", },
justifyContent: "space-between", heroInner: {
borderBottomWidth: 1, paddingTop: 50,
borderBottomColor: "#16213e", paddingBottom: spacing.lg,
minHeight: 260,
},
heroBlur: {
backgroundColor: 'rgba(22, 33, 62, 0.4)',
},
heroBlurFallback: {
backgroundColor: 'rgba(22, 33, 62, 0.6)',
}, },
backButton: { backButton: {
padding: 4, position: 'absolute',
top: 50,
left: spacing.md,
zIndex: 10,
}, },
title: { backButtonInner: {
fontSize: 20, width: 40,
fontWeight: "bold", height: 40,
color: "#fff", borderRadius: 20,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: colors.glass.border,
}, },
packInfo: { heroContent: {
padding: 24, alignItems: 'center',
alignItems: "center", paddingHorizontal: spacing.lg,
borderBottomWidth: 1, paddingTop: spacing.xl,
borderBottomColor: "#16213e",
}, },
iconContainer: { iconContainer: {
width: 64, width: 80,
height: 64, height: 80,
borderRadius: 32, borderRadius: 40,
backgroundColor: "rgba(255, 255, 255, 0.05)", backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
marginBottom: 16, marginBottom: spacing.md,
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.15)',
},
iconContainerPremium: {
borderColor: colors.premium.gold,
backgroundColor: colors.premium.goldLight,
}, },
emoji: { 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: { description: {
fontSize: 16, ...typography.body,
color: "#ccc", color: colors.textSecondary,
textAlign: "center", textAlign: 'center',
lineHeight: 24, lineHeight: 24,
paddingHorizontal: spacing.md,
}, },
sectionHeader: { statsBadge: {
padding: 24, marginTop: spacing.md,
paddingBottom: 8, backgroundColor: 'rgba(255, 255, 255, 0.1)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.glass.border,
}, },
sectionTitle: { statsText: {
fontSize: 18, ...typography.subhead,
fontWeight: "bold", color: colors.textSecondary,
color: "#e94560", fontWeight: '500',
}, },
list: { list: {
padding: 24, padding: spacing.md,
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
}, },
card: { questionCard: {
backgroundColor: "#16213e", marginBottom: spacing.md,
borderRadius: 12, overflow: 'hidden',
padding: 16, },
marginBottom: 12, questionContent: {
borderWidth: 1, padding: spacing.md,
borderColor: "#0f3460",
}, },
questionText: { questionText: {
fontSize: 16, ...typography.body,
color: "#fff", color: colors.text,
marginBottom: 8, marginBottom: spacing.sm,
lineHeight: 22, lineHeight: 24,
}, },
intensityContainer: { partnerTextContainer: {
flexDirection: "row", 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: { partnerText: {
fontSize: 14, ...typography.caption1,
color: "#aaa", color: colors.premium.rose,
fontStyle: "italic", fontStyle: 'italic',
marginBottom: 8,
}, },
footerContainer: { footerContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
marginTop: 8, marginTop: spacing.xs,
}, },
targetsContainer: { intensityContainer: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', },
alignItems: 'center', badgesContainer: {
gap: 6, flexDirection: 'row',
gap: spacing.sm,
}, },
targetBadge: { targetBadge: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.1)', backgroundColor: 'rgba(255, 255, 255, 0.08)',
paddingHorizontal: 10, paddingHorizontal: spacing.sm,
paddingVertical: 6, paddingVertical: spacing.xs,
borderRadius: 6, borderRadius: radius.sm,
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.2)', borderColor: colors.glass.border,
}, },
initiatorBadge: { coupleIconGroup: {
backgroundColor: 'rgba(243, 156, 18, 0.15)', flexDirection: 'row',
borderColor: 'rgba(243, 156, 18, 0.3)', alignItems: 'center',
},
emptyContainer: {
alignItems: 'center',
paddingVertical: spacing.xxl,
gap: spacing.md,
},
emptyText: {
...typography.body,
color: colors.textSecondary,
}, },
modalOverlay: { modalOverlay: {
flex: 1, flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.7)', backgroundColor: 'rgba(0, 0, 0, 0.7)',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
padding: 24, padding: spacing.lg,
}, },
tooltipContainer: { tooltipContainer: {
backgroundColor: '#16213e',
borderRadius: 16,
padding: 24,
maxWidth: 320, maxWidth: 320,
borderWidth: 1, overflow: 'hidden',
borderColor: '#0f3460',
}, },
tooltipTitle: { tooltipTitle: {
fontSize: 18, ...typography.title3,
fontWeight: 'bold', color: colors.text,
color: '#fff', marginBottom: spacing.sm,
marginBottom: 12,
}, },
tooltipDescription: { tooltipDescription: {
fontSize: 14, ...typography.body,
color: '#ccc', color: colors.textSecondary,
lineHeight: 22, lineHeight: 24,
marginBottom: 20, marginBottom: spacing.lg,
}, },
tooltipButton: { tooltipButton: {
backgroundColor: '#e94560', borderRadius: radius.md,
borderRadius: 8, overflow: 'hidden',
paddingVertical: 12, },
tooltipButtonGradient: {
paddingVertical: spacing.sm,
alignItems: 'center', alignItems: 'center',
}, },
tooltipButtonText: { tooltipButtonText: {
fontSize: 16, ...typography.headline,
fontWeight: '600', color: colors.text,
color: '#fff',
}, },
}); });
@@ -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;
+224
View File
@@ -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, runOnJS,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme"; import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme";
import { Events } from "../lib/analytics";
interface Props { interface Props {
onComplete: () => void; onComplete: () => void;
@@ -110,6 +111,19 @@ export default function SwipeTutorial({ onComplete }: Props) {
const currentStep_ = STEPS[currentStep]; const currentStep_ = STEPS[currentStep];
const isSwipeStep = currentStep_.isSwipe; 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(() => { useEffect(() => {
// Reset animations for current step // Reset animations for current step
cardTranslateX.value = 0; cardTranslateX.value = 0;
@@ -217,11 +231,13 @@ export default function SwipeTutorial({ onComplete }: Props) {
if (currentStep < STEPS.length - 1) { if (currentStep < STEPS.length - 1) {
setCurrentStep(currentStep + 1); setCurrentStep(currentStep + 1);
} else { } else {
Events.tutorialCompleted("swipe");
onComplete(); onComplete();
} }
}; };
const handleSkip = () => { const handleSkip = () => {
Events.tutorialSkipped("swipe", currentStep);
onComplete(); 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;
+1
View File
@@ -2,3 +2,4 @@ export { GradientBackground } from './GradientBackground';
export { GlassCard } from './GlassCard'; export { GlassCard } from './GlassCard';
export { GlassButton } from './GlassButton'; export { GlassButton } from './GlassButton';
export { GlassInput } from './GlassInput'; export { GlassInput } from './GlassInput';
export { ShimmerEffect } from './ShimmerEffect';
+10
View File
@@ -166,4 +166,14 @@ export const Events = {
// Invite events // Invite events
inviteCodeCopied: () => logEvent("invite_code_copied"), 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);
}
+25 -2
View File
@@ -28,8 +28,8 @@ export const colors = {
// Text // Text
text: '#ffffff', text: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.6)', textSecondary: 'rgba(255, 255, 255, 0.7)', // Bumped from 0.6 for better contrast
textTertiary: 'rgba(255, 255, 255, 0.4)', textTertiary: 'rgba(255, 255, 255, 0.5)', // Bumped from 0.4 for accessibility
// Semantic // Semantic
success: '#2ECC71', success: '#2ECC71',
@@ -38,6 +38,17 @@ export const colors = {
warningLight: 'rgba(243, 156, 18, 0.15)', warningLight: 'rgba(243, 156, 18, 0.15)',
error: '#E74C3C', error: '#E74C3C',
errorLight: 'rgba(231, 76, 60, 0.15)', 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 = { export const gradients = {
@@ -51,6 +62,18 @@ export const gradients = {
success: ['#2ECC71', '#27ae60'], success: ['#2ECC71', '#27ae60'],
warning: ['#F39C12', '#e67e22'], warning: ['#F39C12', '#e67e22'],
error: ['#E74C3C', '#c0392b'], 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 = { export const spacing = {
+136
View File
@@ -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 -- 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" CREATE POLICY "Users can view chat media in their matches"
ON storage.objects FOR SELECT TO authenticated ON storage.objects FOR SELECT TO authenticated
USING ( USING (
@@ -1035,7 +1036,7 @@ CREATE POLICY "Users can view chat media in their matches"
AND EXISTS ( AND EXISTS (
SELECT 1 FROM matches m SELECT 1 FROM matches m
JOIN profiles p ON p.couple_id = m.couple_id 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() AND p.id = auth.uid()
) )
); );
@@ -1047,7 +1048,7 @@ CREATE POLICY "Users can upload chat media to their matches"
AND EXISTS ( AND EXISTS (
SELECT 1 FROM matches m SELECT 1 FROM matches m
JOIN profiles p ON p.couple_id = m.couple_id 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() AND p.id = auth.uid()
) )
); );
@@ -1059,7 +1060,7 @@ CREATE POLICY "Users can delete their own chat media"
AND EXISTS ( AND EXISTS (
SELECT 1 FROM matches m SELECT 1 FROM matches m
JOIN profiles p ON p.couple_id = m.couple_id 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() 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()
)
);
+86
View File
@@ -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
+76
View File
@@ -0,0 +1,76 @@
# Shared Package - Agent Guidelines
**STRICT RULES - DO NOT VIOLATE**
## Architecture
This is a shared TypeScript package (`@sauci/shared`) containing:
- **Types**: Shared TypeScript type definitions
- **Package Name**: `@sauci/shared`
## Directory Structure
```
src/
index.ts # Main export file
types/ # Type definitions
package.json # Package configuration
tsconfig.json # TypeScript config
```
## Critical Rules
### Purpose
This package exists ONLY for sharing types across apps. It should contain:
- TypeScript interfaces
- Type definitions
- Enums
- Constants (if truly shared)
**NO runtime code** - types only.
### Exports
All exports must go through `src/index.ts`:
```typescript
export * from './types';
```
### Usage in Apps
Import in consuming apps:
```typescript
import type { Profile, Couple, Match } from '@sauci/shared';
```
### Type Definitions
Types should match database schema. Core entities:
- `Profile` - User profile extending Supabase auth
- `Couple` - Two users linked via invite code
- `QuestionPack` - Collection of questions
- `Question` - Individual question with intensity
- `Response` - User's answer (yes/no/maybe)
- `Match` - Created when both partners answer positively
### Adding Types
When adding new types:
1. Add to appropriate file in `src/types/`
2. Export from `src/types/index.ts`
3. Re-export from `src/index.ts`
4. Run `npm run build` from root to verify
### Versioning
Types are internal - no npm publishing. Changes propagate automatically via Turborepo workspace linking.
## Forbidden Actions
1. **DO NOT** add runtime code (functions, classes with logic)
2. **DO NOT** add dependencies (except dev dependencies)
3. **DO NOT** create circular dependencies between types
4. **DO NOT** duplicate types that exist in Supabase generated types
5. **DO NOT** break backwards compatibility without updating all consumers
6. **DO NOT** add app-specific types (keep them generic/shared)