This commit is contained in:
jjbrunton committed 2026-01-01 15:42:22 +00:00
1 parent ef7bc8c90d
commit 3fcbea8d60
68 files changed
+2872 -127

No files matched your search

+9
View File
@@ -15,6 +15,7 @@ import { AdminsPage } from '@/pages/admins/AdminsPage';
import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage'; import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
import { FeedbackPage } from '@/pages/FeedbackPage'; import { FeedbackPage } from '@/pages/FeedbackPage';
import { UsageInsightsPage } from '@/pages/UsageInsightsPage'; import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
import { UserActivityPage } from '@/pages/activity/UserActivityPage';
import { AiSettingsPage } from '@/pages/AiSettingsPage'; import { AiSettingsPage } from '@/pages/AiSettingsPage';
import { AppSettingsPage } from '@/pages/AppSettingsPage'; import { AppSettingsPage } from '@/pages/AppSettingsPage';
@@ -96,6 +97,14 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/activity"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_ACTIVITY}>
<UserActivityPage />
</ProtectedRoute>
}
/>
{/* System */} {/* System */}
<Route <Route
+21 -1
View File
@@ -15,6 +15,8 @@ import {
Bot, Bot,
Smartphone, Smartphone,
X, X,
Home,
Activity,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@@ -29,6 +31,14 @@ interface NavItem {
requiredPermission?: PermissionKey; requiredPermission?: PermissionKey;
} }
const dashboardNav: NavItem[] = [
{
label: 'Dashboard',
href: '/',
icon: <Home className="h-5 w-5" />,
},
];
const contentNav: NavItem[] = [ const contentNav: NavItem[] = [
{ {
label: 'Categories', label: 'Categories',
@@ -45,6 +55,12 @@ const usersNav: NavItem[] = [
icon: <Users className="h-5 w-5" />, icon: <Users className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.VIEW_USERS, requiredPermission: PERMISSION_KEYS.VIEW_USERS,
}, },
{
label: 'User Activity',
href: '/activity',
icon: <Activity className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.VIEW_ACTIVITY,
},
{ {
label: 'Usage Insights', label: 'Usage Insights',
href: '/usage-insights', href: '/usage-insights',
@@ -112,7 +128,9 @@ export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
const NavLink = ({ item }: { item: NavItem }) => { const NavLink = ({ item }: { item: NavItem }) => {
if (item.requiredPermission && !hasPermission(item.requiredPermission)) return null; if (item.requiredPermission && !hasPermission(item.requiredPermission)) return null;
const isActive = location.pathname.startsWith(item.href); const isActive = item.href === '/'
? location.pathname === '/'
: location.pathname.startsWith(item.href);
return ( return (
<Link <Link
@@ -214,6 +232,7 @@ export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
{/* Navigation */} {/* Navigation */}
<div className="flex-1 space-y-6 overflow-auto px-3 py-2"> <div className="flex-1 space-y-6 overflow-auto px-3 py-2">
<NavSection title="Dashboard" items={dashboardNav} />
<NavSection title="Content" items={contentNav} /> <NavSection title="Content" items={contentNav} />
<NavSection title="Users" items={usersNav} /> <NavSection title="Users" items={usersNav} />
<NavSection title="System" items={systemNav} /> <NavSection title="System" items={systemNav} />
@@ -310,6 +329,7 @@ export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
{/* Navigation */} {/* Navigation */}
<div className="flex-1 space-y-6 overflow-auto px-3 py-4"> <div className="flex-1 space-y-6 overflow-auto px-3 py-4">
<NavSection title="Dashboard" items={dashboardNav} />
<NavSection title="Content" items={contentNav} /> <NavSection title="Content" items={contentNav} />
<NavSection title="Users" items={usersNav} /> <NavSection title="Users" items={usersNav} />
<NavSection title="System" items={systemNav} /> <NavSection title="System" items={systemNav} />
+2
View File
@@ -18,6 +18,7 @@ export const PERMISSION_KEYS = {
VIEW_MEDIA: 'view_media', VIEW_MEDIA: 'view_media',
VIEW_MATCHES: 'view_matches', VIEW_MATCHES: 'view_matches',
VIEW_RESPONSES: 'view_responses', VIEW_RESPONSES: 'view_responses',
VIEW_ACTIVITY: 'view_activity',
// System permissions // System permissions
MANAGE_CODES: 'manage_codes', MANAGE_CODES: 'manage_codes',
MANAGE_ADMINS: 'manage_admins', MANAGE_ADMINS: 'manage_admins',
@@ -38,6 +39,7 @@ export const PERMISSION_METADATA: Record<PermissionKey, { label: string; descrip
[PERMISSION_KEYS.VIEW_MEDIA]: { label: 'Can view media', description: 'View user photos and media', group: 'users' }, [PERMISSION_KEYS.VIEW_MEDIA]: { label: 'Can view media', description: 'View user photos and media', group: 'users' },
[PERMISSION_KEYS.VIEW_MATCHES]: { label: 'Can view matches', description: 'View user matches and match details', group: 'users' }, [PERMISSION_KEYS.VIEW_MATCHES]: { label: 'Can view matches', description: 'View user matches and match details', group: 'users' },
[PERMISSION_KEYS.VIEW_RESPONSES]: { label: 'Can view responses', description: 'View user question responses', group: 'users' }, [PERMISSION_KEYS.VIEW_RESPONSES]: { label: 'Can view responses', description: 'View user question responses', group: 'users' },
[PERMISSION_KEYS.VIEW_ACTIVITY]: { label: 'Can view activity', description: 'View user activity feed (responses, matches, messages, signups)', group: 'users' },
[PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' }, [PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' },
[PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' }, [PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' },
[PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' }, [PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' },
+20 -7
View File
@@ -16,7 +16,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { Search, Crown, Users, ChevronRight, HardDrive } from 'lucide-react'; import { Search, Crown, Users, ChevronRight, HardDrive, MailWarning } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
interface Profile { interface Profile {
@@ -27,6 +27,8 @@ 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;
last_sign_in_at: string | null;
email_confirmed_at: string | null;
storage_bytes?: number; storage_bytes?: number;
partner_is_premium?: boolean; partner_is_premium?: boolean;
} }
@@ -48,11 +50,9 @@ export function UsersPage() {
const fetchProfiles = useCallback(async () => { const fetchProfiles = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
// Fetch profiles // Fetch profiles with auth info (includes last_sign_in_at)
const { data: profilesData, error } = await supabase const { data: profilesData, error } = await supabase
.from('profiles') .rpc('get_profiles_with_auth_info');
.select('*')
.order('created_at', { ascending: false });
if (error) throw error; if (error) throw error;
@@ -197,13 +197,14 @@ export function UsersPage() {
<TableHead>Couple</TableHead> <TableHead>Couple</TableHead>
<TableHead>Storage</TableHead> <TableHead>Storage</TableHead>
<TableHead>Joined</TableHead> <TableHead>Joined</TableHead>
<TableHead>Last Active</TableHead>
<TableHead className="w-12"></TableHead> <TableHead className="w-12"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredProfiles.length === 0 ? ( {filteredProfiles.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground"> <TableCell colSpan={8} 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>
@@ -224,7 +225,14 @@ export function UsersPage() {
</Link> </Link>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{profile.email || '—'} <div className="flex items-center gap-1.5">
{profile.email || '—'}
{profile.email && !profile.email_confirmed_at && (
<span title="Email not verified">
<MailWarning className="h-3.5 w-3.5 text-amber-500" />
</span>
)}
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
{profile.is_premium ? ( {profile.is_premium ? (
@@ -268,6 +276,11 @@ export function UsersPage() {
? format(new Date(profile.created_at), 'MMM d, yyyy') ? format(new Date(profile.created_at), 'MMM d, yyyy')
: '—'} : '—'}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground text-sm">
{profile.last_sign_in_at
? format(new Date(profile.last_sign_in_at), 'MMM d, yyyy h:mm a')
: 'Never'}
</TableCell>
<TableCell> <TableCell>
<Link to={`/users/${profile.id}`}> <Link to={`/users/${profile.id}`}>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
+5 -5
View File
@@ -93,7 +93,7 @@ android {
applicationId 'com.sauci.app' applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2 versionCode 3
versionName "1.0.0" versionName "1.0.0"
} }
signingConfigs { signingConfigs {
@@ -104,10 +104,10 @@ android {
keyPassword 'android' keyPassword 'android'
} }
release { release {
storeFile file('upload-keystore.jks') storeFile file('../sauci-release.keystore')
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "changeit" storePassword "create01"
keyAlias 'upload' keyAlias 'sauci'
keyPassword System.getenv("KEY_PASSWORD") ?: "changeit" keyPassword "create01"
} }
} }
buildTypes { buildTypes {
+12 -2
View File
@@ -12,7 +12,12 @@
"package_name": "com.sauci.app" "package_name": "com.sauci.app"
} }
}, },
"oauth_client": [], "oauth_client": [
{
"client_id": "764866133492-e78o2rh3rdjc4rfj2j6r2j77b065kggm.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [ "api_key": [
{ {
"current_key": "AIzaSyDOAqIXAd_dy7L9ZflPvNkJdNXWMaGZ1O4" "current_key": "AIzaSyDOAqIXAd_dy7L9ZflPvNkJdNXWMaGZ1O4"
@@ -20,7 +25,12 @@
], ],
"services": { "services": {
"appinvite_service": { "appinvite_service": {
"other_platform_oauth_client": [] "other_platform_oauth_client": [
{
"client_id": "764866133492-e78o2rh3rdjc4rfj2j6r2j77b065kggm.apps.googleusercontent.com",
"client_type": 3
}
]
} }
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 653 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
{"version":0,"bundler":"metro","fileMetadata":{"android":{"bundle":"_expo/static/js/android/entry-295eb642e86589562852e8b2e825bdc0.hbc","assets":[{"path":"assets\\778ffc9fe8773a878e9c30a6304784de","ext":"png"},{"path":"assets\\c79c3606a1cf168006ad3979763c7e0c","ext":"png"},{"path":"assets\\02bc1fa7c0313217bde2d65ccbff40c9","ext":"png"},{"path":"assets\\35ba0eaec5a4f5ed12ca16fabeae451d","ext":"png"},{"path":"assets\\0a328cd9c1afd0afe8e3b1ec5165b1b4","ext":"png"},{"path":"assets\\61ca7e64b7d605716c57706cef640b9a","ext":"png"},{"path":"assets\\3cd68ccdb8938e3711da2e8831b85493","ext":"png"},{"path":"assets\\aff2c65b39a296d4f7e96d0f58169170","ext":"png"},{"path":"assets\\d8e7601e3df962f83c62371ac14964d8","ext":"png"},{"path":"assets\\3cd68ccdb8938e3711da2e8831b85493","ext":"png"},{"path":"assets\\0747a1317bbe9c6fc340b889ef8ab3ae","ext":"png"},{"path":"assets\\d84e297c3b3e49a614248143d53e40ca","ext":"png"},{"path":"assets\\1190ab078c57159f4245a328118fcd9a","ext":"png"},{"path":"assets\\78c625386b4d0690b421eb0fc78f7b9c","ext":"png"},{"path":"assets\\0747a1317bbe9c6fc340b889ef8ab3ae","ext":"png"},{"path":"assets\\4403c6117ec30c859bc95d70ce4a71d3","ext":"png"},{"path":"assets\\069d99eb1fa6712c0b9034a58c6b57dd","ext":"png"},{"path":"assets\\c3273c9e5321f20d1e42c2efae2578c4","ext":"png"},{"path":"assets\\286d67d3f74808a60a78d3ebf1a5fb57","ext":"png"},{"path":"assets\\d1ea1496f9057eb392d5bbf3732a61b7","ext":"png"},{"path":"assets\\19eeb73b9593a38f8e9f418337fc7d10","ext":"png"},{"path":"assets\\ab19f4cbc543357183a20571f68380a3","ext":"png"},{"path":"assets\\d8b800c443b8972542883e0b9de2bdc6","ext":"png"},{"path":"assets\\412dd9275b6b48ad28f5e3d81bb1f626","ext":"png"},{"path":"assets\\20e71bdf79e3a97bf55fd9e164041578","ext":"png"},{"path":"assets\\3a2ba31570920eeb9b1d217cabe58315","ext":"ttf"},{"path":"assets\\31b5ffea3daddc69dd01a1f3d6cf63c5","ext":"ttf"},{"path":"assets\\140c53a7643ea949007aa9a282153849","ext":"ttf"},{"path":"assets\\a76d309774d33d9856f650bed4292a23","ext":"ttf"},{"path":"assets\\b49ae8ab2dbccb02c4d11caaacf09eab","ext":"ttf"},{"path":"assets\\b06871f281fee6b241d60582ae9369b9","ext":"ttf"},{"path":"assets\\1f77739ca9ff2188b539c36f30ffa2be","ext":"ttf"},{"path":"assets\\605ed7926cf39a2ad5ec2d1f9d391d3d","ext":"ttf"},{"path":"assets\\3b89dd103490708d19a95adcae52210e","ext":"ttf"},{"path":"assets\\370dd5af19f8364907b6e2c41f45dbbf","ext":"ttf"},{"path":"assets\\adec7d6f310bc577f05e8fe06a5daccf","ext":"ttf"},{"path":"assets\\56c8d80832e37783f12c05db7c8849e2","ext":"ttf"},{"path":"assets\\e20945d7c929279ef7a6f1db184a4470","ext":"ttf"},{"path":"assets\\6148e7019854f3bde85b633cb88f3c25","ext":"ttf"},{"path":"assets\\b62641afc9ab487008e996a5c5865e56","ext":"ttf"},{"path":"assets\\4e85bc9ebe07e0340c9c4fc2f6c38908","ext":"ttf"},{"path":"assets\\f7c53c47a66934504fcbc7cc164895a7","ext":"ttf"},{"path":"assets\\d2285965fe34b05465047401b8595dd0","ext":"ttf"},{"path":"assets\\1681f34aaca71b8dfb70756bca331eb2","ext":"ttf"},{"path":"assets\\cf3578021b5094cf7ab6322f2ee617a0","ext":"png"}]}}}
+7 -1
View File
@@ -66,7 +66,13 @@
], ],
"@sentry/react-native", "@sentry/react-native",
"expo-font", "expo-font",
"expo-asset" "expo-asset",
[
"@react-native-google-signin/google-signin",
{
"iosUrlScheme": "com.googleusercontent.apps.861972684340-0u2as8i5equfhmhmvjihottkf0ig7237"
}
]
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true
+11 -4
View File
@@ -84,9 +84,10 @@ export default function AppLayout() {
const wentToBackgroundAt = useRef<number | null>(null); const wentToBackgroundAt = useRef<number | null>(null);
// Check if we're on screens that should hide the tab bar // Check if we're on screens that should hide the tab bar
const isOnOnboarding = segments.includes("onboarding"); const segmentStrings = segments as string[];
const isOnPairing = segments.includes("pairing"); const isOnOnboarding = segmentStrings.includes("onboarding");
const isOnChat = segments.includes("chat"); const isOnPairing = segmentStrings.includes("pairing");
const isOnChat = segmentStrings.includes("chat");
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat; const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat;
// Redirect to login when not authenticated, or to onboarding if not completed // Redirect to login when not authenticated, or to onboarding if not completed
@@ -309,10 +310,16 @@ export default function AppLayout() {
.single(); .single();
if (match) { if (match) {
// Handle the joined question data from the query
const questionData = match.question;
const questionText = Array.isArray(questionData)
? questionData[0]?.text
: (questionData as { text: string } | null)?.text;
addMessage({ addMessage({
...newMessage, ...newMessage,
delivered_at: newMessage.delivered_at || new Date().toISOString(), delivered_at: newMessage.delivered_at || new Date().toISOString(),
match: match as { id: string; question: { text: string } }, match: { id: match.id, question: { text: questionText || "" } },
}); });
} }
} }
+1 -1
View File
@@ -410,7 +410,7 @@ export default function OnboardingScreen() {
Show me everything Show me everything
</Text> </Text>
<Text style={styles.explicitDescription}> <Text style={styles.explicitDescription}>
Include 18+ question packs Include spicy question packs
</Text> </Text>
</View> </View>
<View style={[ <View style={[
+2 -2
View File
@@ -31,7 +31,7 @@ export default function PairingScreen() {
// Redirect if already paired // Redirect if already paired
useEffect(() => { useEffect(() => {
if (couple && partner) { if (couple && partner) {
router.replace("/(app)/"); router.replace("/(app)");
} }
}, [couple, partner]); }, [couple, partner]);
@@ -124,7 +124,7 @@ export default function PairingScreen() {
Events.coupleJoined(); Events.coupleJoined();
Alert.alert("Success", "You are now paired!", [ Alert.alert("Success", "You are now paired!", [
{ text: "Let's Go", onPress: () => router.replace("/(app)/") } { text: "Let's Go", onPress: () => router.replace("/(app)") }
]); ]);
} catch (error: any) { } catch (error: any) {
Alert.alert("Error", getPairingError(error)); Alert.alert("Error", getPairingError(error));
+2 -2
View File
@@ -726,8 +726,8 @@ export default function ProfileScreen() {
<Ionicons name="flame" size={20} color={colors.text} /> <Ionicons name="flame" size={20} color={colors.text} />
</LinearGradient> </LinearGradient>
<View style={styles.rowTextContainer}> <View style={styles.rowTextContainer}>
<Text style={styles.rowValue}>Explicit Content</Text> <Text style={styles.rowValue}>Spicy Content</Text>
<Text style={styles.rowLabel}>Show 18+ question packs</Text> <Text style={styles.rowLabel}>Show spicy question packs</Text>
</View> </View>
</View> </View>
<Switch <Switch
+158 -7
View File
@@ -2,7 +2,17 @@ import { useState, useEffect, useRef } from "react";
import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native"; import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native";
import { useLocalSearchParams, router } from "expo-router"; import { useLocalSearchParams, router } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated"; import Animated, {
FadeIn,
FadeInUp,
useSharedValue,
useAnimatedStyle,
withRepeat,
withSequence,
withTiming,
interpolate,
Easing,
} from "react-native-reanimated";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { supabase } from "../../src/lib/supabase"; import { supabase } from "../../src/lib/supabase";
import { usePacksStore, useAuthStore } from "../../src/store"; import { usePacksStore, useAuthStore } from "../../src/store";
@@ -27,6 +37,74 @@ export default function SwipeScreen() {
const { partner, couple } = useAuthStore(); const { partner, couple } = useAuthStore();
const hasTrackedExhausted = useRef(false); const hasTrackedExhausted = useRef(false);
// Ambient orb breathing animations
const orbBreathing1 = useSharedValue(0);
const orbBreathing2 = useSharedValue(0);
const orbDrift = useSharedValue(0);
const shimmerPosition = useSharedValue(-1);
useEffect(() => {
// Primary orb breathing - 6 second cycle
orbBreathing1.value = withRepeat(
withSequence(
withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
// Secondary orb breathing - offset timing for variation
orbBreathing2.value = withRepeat(
withSequence(
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
// Subtle vertical drift - 8 second cycle
orbDrift.value = withRepeat(
withSequence(
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 4000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
// Progress bar shimmer sweep - 2.5 second cycle
shimmerPosition.value = withRepeat(
withTiming(2, { duration: 2500, easing: Easing.linear }),
-1,
false
);
}, []);
const orbStyle1 = useAnimatedStyle(() => ({
opacity: interpolate(orbBreathing1.value, [0, 1], [0.25, 0.5]),
transform: [
{ translateY: interpolate(orbDrift.value, [0, 1], [0, -20]) },
{ scale: interpolate(orbBreathing1.value, [0, 1], [1, 1.1]) },
],
}));
const orbStyle2 = useAnimatedStyle(() => ({
opacity: interpolate(orbBreathing2.value, [0, 1], [0.2, 0.4]),
transform: [
{ translateY: interpolate(orbDrift.value, [0, 1], [20, 0]) },
{ scale: interpolate(orbBreathing2.value, [0, 1], [1, 1.1]) },
],
}));
const shimmerStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: interpolate(shimmerPosition.value, [-1, 2], [-60, 220]) },
],
}));
useEffect(() => { useEffect(() => {
fetchPacks().then(() => fetchQuestions()); fetchPacks().then(() => fetchQuestions());
checkTutorial(); checkTutorial();
@@ -323,6 +401,24 @@ export default function SwipeScreen() {
return ( return (
<GradientBackground> <GradientBackground>
{/* Ambient Orbs */}
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
<LinearGradient
colors={[colors.primaryGlow, 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
<Animated.View style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]} pointerEvents="none">
<LinearGradient
colors={[colors.secondaryGlow, 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 0, y: 0 }}
/>
</Animated.View>
<View style={styles.container}> <View style={styles.container}>
{/* Header */} {/* Header */}
<Animated.View <Animated.View
@@ -333,7 +429,7 @@ export default function SwipeScreen() {
<Text style={styles.progressText}> <Text style={styles.progressText}>
{currentIndex + 1} of {questions.length} {currentIndex + 1} of {questions.length}
</Text> </Text>
<View style={styles.progressBar}> <View style={[styles.progressBar, styles.progressBarEnhanced]}>
<Animated.View <Animated.View
style={[ style={[
styles.progressFill, styles.progressFill,
@@ -346,6 +442,15 @@ export default function SwipeScreen() {
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
style={styles.progressGradient} style={styles.progressGradient}
/> />
{/* Shimmer sweep */}
<Animated.View style={[styles.progressShimmer, shimmerStyle]}>
<LinearGradient
colors={['transparent', 'rgba(255,255,255,0.3)', 'transparent']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={StyleSheet.absoluteFill}
/>
</Animated.View>
</Animated.View> </Animated.View>
</View> </View>
</View> </View>
@@ -353,9 +458,14 @@ export default function SwipeScreen() {
{/* Card Stack */} {/* Card Stack */}
<View style={styles.cardContainer}> <View style={styles.cardContainer}>
{/* Background card (next) */} {/* Third card (deepest) */}
{questions[currentIndex + 2] && (
<View style={[styles.stackCard, styles.stackCardThird]} />
)}
{/* Second card */}
{questions[currentIndex + 1] && ( {questions[currentIndex + 1] && (
<View style={styles.backgroundCard} /> <View style={[styles.stackCard, styles.stackCardSecond]} />
)} )}
{/* Active card */} {/* Active card */}
@@ -395,6 +505,26 @@ const styles = StyleSheet.create({
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}, },
// Ambient orbs - more visible
ambientOrb: {
position: 'absolute',
width: 300,
height: 300,
borderRadius: 150,
},
orbTopRight: {
top: 60,
right: -40,
},
orbBottomLeft: {
bottom: 180,
left: -40,
},
orbGradient: {
width: '100%',
height: '100%',
borderRadius: 150,
},
header: { header: {
paddingTop: 60, paddingTop: 60,
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
@@ -415,21 +545,35 @@ const styles = StyleSheet.create({
borderRadius: 3, borderRadius: 3,
overflow: "hidden", overflow: "hidden",
}, },
progressBarEnhanced: {
height: 8,
borderRadius: 4,
borderWidth: 1,
borderColor: colors.glass.borderLight,
...shadows.glow(colors.primaryGlow),
},
progressFill: { progressFill: {
height: "100%", height: "100%",
borderRadius: 3, borderRadius: 4,
overflow: "hidden", overflow: "hidden",
}, },
progressGradient: { progressGradient: {
flex: 1, flex: 1,
borderRadius: 3, borderRadius: 4,
},
progressShimmer: {
position: 'absolute',
width: 60,
height: '100%',
top: 0,
}, },
cardContainer: { cardContainer: {
flex: 1, flex: 1,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}, },
backgroundCard: { // Card stack
stackCard: {
position: "absolute", position: "absolute",
width: "85%", width: "85%",
height: 480, height: 480,
@@ -437,10 +581,17 @@ const styles = StyleSheet.create({
borderRadius: radius.xxl, borderRadius: radius.xxl,
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.border, borderColor: colors.glass.border,
},
stackCardSecond: {
transform: [{ scale: 0.95 }, { translateY: 12 }], transform: [{ scale: 0.95 }, { translateY: 12 }],
opacity: 0.5, opacity: 0.5,
...shadows.md, ...shadows.md,
}, },
stackCardThird: {
transform: [{ scale: 0.90 }, { translateY: 24 }],
opacity: 0.25,
...shadows.sm,
},
hintContainer: { hintContainer: {
alignItems: "center", alignItems: "center",
paddingBottom: spacing.md, paddingBottom: spacing.md,
+114 -26
View File
@@ -20,6 +20,7 @@ import Animated, {
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
// Safely import Apple Authentication - may not be available in Expo Go // Safely import Apple Authentication - may not be available in Expo Go
let AppleAuthentication: typeof import("expo-apple-authentication") | null = null; let AppleAuthentication: typeof import("expo-apple-authentication") | null = null;
let GoogleSignin: typeof import("@react-native-google-signin/google-signin").GoogleSignin | null = null;
if (Platform.OS === "ios") { if (Platform.OS === "ios") {
try { try {
@@ -28,6 +29,20 @@ if (Platform.OS === "ios") {
AppleAuthentication = null; AppleAuthentication = null;
} }
} }
if (Platform.OS === "android") {
try {
const googleModule = require("@react-native-google-signin/google-signin");
GoogleSignin = googleModule.GoogleSignin;
if (GoogleSignin) {
GoogleSignin.configure({
webClientId: "764866133492-e78o2rh3rdjc4rfj2j6r2j77b065kggm.apps.googleusercontent.com",
});
}
} catch {
GoogleSignin = null;
}
}
import { supabase } from "../../src/lib/supabase"; import { supabase } from "../../src/lib/supabase";
import { getAuthError } from "../../src/lib/errors"; import { getAuthError } from "../../src/lib/errors";
import { Events } from "../../src/lib/analytics"; import { Events } from "../../src/lib/analytics";
@@ -55,6 +70,7 @@ export default function LoginScreen() {
const [isSignUp, setIsSignUp] = useState(false); const [isSignUp, setIsSignUp] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isAppleAuthAvailable, setIsAppleAuthAvailable] = useState(false); const [isAppleAuthAvailable, setIsAppleAuthAvailable] = useState(false);
const [isGoogleAuthAvailable, setIsGoogleAuthAvailable] = useState(false);
const { isAuthenticated } = useAuthStore(); const { isAuthenticated } = useAuthStore();
// Check Apple Auth availability asynchronously // Check Apple Auth availability asynchronously
@@ -68,6 +84,13 @@ export default function LoginScreen() {
} }
}, []); }, []);
// Check Google Auth availability
useEffect(() => {
if (Platform.OS === "android" && GoogleSignin) {
setIsGoogleAuthAvailable(true);
}
}, []);
// Cooldown timer for resend verification email // Cooldown timer for resend verification email
useEffect(() => { useEffect(() => {
if (resendCooldown <= 0) return; if (resendCooldown <= 0) return;
@@ -301,6 +324,43 @@ export default function LoginScreen() {
} }
}; };
const handleGoogleSignIn = async () => {
console.log("[Google Sign In] Starting, isGoogleAuthAvailable:", isGoogleAuthAvailable, "GoogleSignin:", !!GoogleSignin);
if (isGoogleAuthAvailable && GoogleSignin) {
try {
console.log("[Google Sign In] Checking Play Services...");
await GoogleSignin.hasPlayServices();
console.log("[Google Sign In] Calling signIn...");
const response = await GoogleSignin.signIn();
console.log("[Google Sign In] Got response, type:", response.type);
if (response.type === "success" && response.data.idToken) {
console.log("[Google Sign In] Got idToken, signing in with Supabase...");
const { error } = await supabase.auth.signInWithIdToken({
provider: "google",
token: response.data.idToken,
});
if (error) {
console.log("[Google Sign In] Supabase error:", error.message);
Alert.alert("Error", getAuthError(error));
} else {
Events.signIn("google");
}
} else if (response.type === "cancelled") {
console.log("[Google Sign In] User cancelled");
} else {
Alert.alert("Error", "No identity token received from Google");
}
} catch (e: any) {
console.log("[Google Sign In] Caught error:", e.code, e.message);
Alert.alert("Error", "Google sign in failed. Please try again.");
}
}
};
if (isMagicLinkSent) { if (isMagicLinkSent) {
return ( return (
<GradientBackground showAccent> <GradientBackground showAccent>
@@ -540,38 +600,66 @@ export default function LoginScreen() {
</GlassCard> </GlassCard>
</Animated.View> </Animated.View>
{/* Social Sign In */} {/* Social Sign In - iOS only */}
<Animated.View {Platform.OS === "ios" && (
entering={FadeInDown.delay(300).duration(600).springify()} <Animated.View
style={styles.socialContainer} entering={FadeInDown.delay(300).duration(600).springify()}
> style={styles.socialContainer}
<View style={styles.divider}> >
<View style={styles.dividerLine} /> <View style={styles.divider}>
<Text style={styles.dividerText}>or continue with</Text> <View style={styles.dividerLine} />
<View style={styles.dividerLine} /> <Text style={styles.dividerText}>or continue with</Text>
</View> <View style={styles.dividerLine} />
</View>
<View style={styles.socialButtons}> <View style={styles.socialButtons}>
{isAppleAuthAvailable && AppleAuthentication ? ( {isAppleAuthAvailable && AppleAuthentication ? (
<AppleAuthentication.AppleAuthenticationButton <AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN} buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.WHITE} buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.WHITE}
cornerRadius={12} cornerRadius={12}
style={styles.appleButton} style={styles.appleButton}
onPress={handleAppleSignIn} onPress={handleAppleSignIn}
/> />
) : ( ) : (
<GlassButton
variant="secondary"
onPress={handleAppleSignIn}
icon={<Ionicons name="logo-apple" size={20} color={colors.text} />}
style={styles.socialButton}
>
Apple
</GlassButton>
)}
</View>
</Animated.View>
)}
{/* Social Sign In - Android (Google) */}
{Platform.OS === "android" && isGoogleAuthAvailable && (
<Animated.View
entering={FadeInDown.delay(300).duration(600).springify()}
style={styles.socialContainer}
>
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>or continue with</Text>
<View style={styles.dividerLine} />
</View>
<View style={styles.socialButtons}>
<GlassButton <GlassButton
variant="secondary" variant="secondary"
onPress={handleAppleSignIn} onPress={handleGoogleSignIn}
icon={<Ionicons name="logo-apple" size={20} color={colors.text} />} icon={<Ionicons name="logo-google" size={20} color={colors.text} />}
style={styles.socialButton} style={styles.socialButton}
fullWidth
> >
Apple Continue with Google
</GlassButton> </GlassButton>
)} </View>
</View> </Animated.View>
</Animated.View> )}
</View> </View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
+1 -1
View File
@@ -24,7 +24,7 @@ import React, { useEffect, useRef } from "react";
import { Platform, View, AppState, AppStateStatus } from "react-native"; import { Platform, View, AppState, AppStateStatus } from "react-native";
// Suppress useLayoutEffect warning on server-side rendering // Suppress useLayoutEffect warning on server-side rendering
if (Platform.OS === "web" && typeof window === "undefined") { if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
React.useLayoutEffect = useEffect; React.useLayoutEffect = useEffect;
// Also silence the warning in case some libraries ignore the monkey-patch // Also silence the warning in case some libraries ignore the monkey-patch
const originalError = console.error; const originalError = console.error;
+1 -1
View File
@@ -30,7 +30,7 @@ export default function Index() {
} }
// Otherwise go to home // Otherwise go to home
return <Redirect href="/(app)/" />; return <Redirect href="/(app)" />;
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
+1
View File
@@ -15,6 +15,7 @@
"@expo/ngrok": "^4.1.0", "@expo/ngrok": "^4.1.0",
"@expo/vector-icons": "~14.0.4", "@expo/vector-icons": "~14.0.4",
"@react-native-async-storage/async-storage": "1.23.1", "@react-native-async-storage/async-storage": "1.23.1",
"@react-native-google-signin/google-signin": "^16.1.1",
"@react-navigation/bottom-tabs": "^7.0.0", "@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0", "@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0", "@react-navigation/native-stack": "^7.0.0",
+202 -33
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, useWindowDimensions } from "react-native"; import { View, Text, StyleSheet, Platform, useWindowDimensions } from "react-native";
import { Gesture, GestureDetector, TouchableOpacity } from "react-native-gesture-handler"; import { Gesture, GestureDetector, TouchableOpacity } from "react-native-gesture-handler";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
@@ -7,11 +7,14 @@ import Animated, {
useSharedValue, useSharedValue,
useAnimatedStyle, useAnimatedStyle,
withSpring, withSpring,
withRepeat,
withSequence,
runOnJS, runOnJS,
interpolate, interpolate,
Extrapolate, Extrapolate,
withTiming, withTiming,
interpolateColor, interpolateColor,
Easing,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme"; import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme";
@@ -48,15 +51,32 @@ export default function SwipeCard({ question, onSwipe }: Props) {
const translateY = useSharedValue(0); const translateY = useSharedValue(0);
const rotation = useSharedValue(0); const rotation = useSharedValue(0);
const scale = useSharedValue(1); const scale = useSharedValue(1);
const shadowExpand = useSharedValue(0);
const idleBreathing = useSharedValue(0);
const isGesturing = useSharedValue(false);
const context = useSharedValue({ x: 0, y: 0 }); const context = useSharedValue({ x: 0, y: 0 });
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const gesture = Gesture.Pan() const gesture = Gesture.Pan()
.activeOffsetX([-15, 15]) .activeOffsetX([-15, 15])
.activeOffsetY([-15, 15]) .activeOffsetY([-15, 15])
.onStart(() => { .onStart(() => {
context.value = { x: translateX.value, y: translateY.value }; context.value = { x: translateX.value, y: translateY.value };
scale.value = withTiming(1.02, { duration: animations.timing.fast }); scale.value = withTiming(1.02, { duration: animations.timing.fast });
shadowExpand.value = withTiming(1, { duration: animations.timing.fast });
isGesturing.value = true;
}) })
.onUpdate((event) => { .onUpdate((event) => {
translateX.value = event.translationX + context.value.x; translateX.value = event.translationX + context.value.x;
@@ -70,6 +90,8 @@ export default function SwipeCard({ question, onSwipe }: Props) {
}) })
.onEnd(() => { .onEnd(() => {
scale.value = withTiming(1, { duration: animations.timing.fast }); scale.value = withTiming(1, { duration: animations.timing.fast });
shadowExpand.value = withTiming(0, { duration: animations.timing.fast });
isGesturing.value = false;
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) { if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
const direction = translateX.value > 0 ? "right" : "left"; const direction = translateX.value > 0 ? "right" : "left";
@@ -94,13 +116,28 @@ export default function SwipeCard({ question, onSwipe }: Props) {
} }
}); });
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => {
transform: [ // Apply idle breathing only when card is at rest
{ translateX: translateX.value }, const idleOffset = isGesturing.value ? 0 : interpolate(idleBreathing.value, [0, 1], [0, -6]);
{ translateY: translateY.value },
{ rotate: `${rotation.value}deg` }, return {
{ scale: scale.value }, transform: [
], { translateX: translateX.value },
{ translateY: translateY.value + idleOffset },
{ rotate: `${rotation.value}deg` },
{ scale: scale.value },
],
};
});
// Dynamic shadow for card lift effect
const cardShadowStyle = useAnimatedStyle(() => ({
shadowOpacity: interpolate(shadowExpand.value, [0, 1], [0.3, 0.5]),
shadowRadius: interpolate(shadowExpand.value, [0, 1], [24, 36]),
shadowOffset: {
width: 0,
height: interpolate(shadowExpand.value, [0, 1], [12, 18]),
},
})); }));
const overlayStyle = (direction: "left" | "right" | "up" | "down") => const overlayStyle = (direction: "left" | "right" | "up" | "down") =>
@@ -159,7 +196,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
<> <>
<View style={[styles.cardWrapper, { width: cardWidth }]}> <View style={[styles.cardWrapper, { width: cardWidth }]}>
<GestureDetector gesture={gesture}> <GestureDetector gesture={gesture}>
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.xl]}> <Animated.View style={[styles.cardOuter, animatedStyle, cardShadowStyle, { shadowColor: '#000' }]}>
{useBlur ? ( {useBlur ? (
<BlurView <BlurView
intensity={blur.medium} intensity={blur.medium}
@@ -171,6 +208,8 @@ export default function SwipeCard({ question, onSwipe }: Props) {
overlayStyle={overlayStyle} overlayStyle={overlayStyle}
handleButtonPress={handleButtonPress} handleButtonPress={handleButtonPress}
onFeedbackPress={handleFeedbackPress} onFeedbackPress={handleFeedbackPress}
translateX={translateX}
translateY={translateY}
/> />
</BlurView> </BlurView>
) : ( ) : (
@@ -180,9 +219,13 @@ export default function SwipeCard({ question, onSwipe }: Props) {
overlayStyle={overlayStyle} overlayStyle={overlayStyle}
handleButtonPress={handleButtonPress} handleButtonPress={handleButtonPress}
onFeedbackPress={handleFeedbackPress} onFeedbackPress={handleFeedbackPress}
translateX={translateX}
translateY={translateY}
/> />
</View> </View>
)} )}
{/* Inner border highlight for depth */}
<View style={styles.innerBorder} pointerEvents="none" />
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
</View> </View>
@@ -202,12 +245,36 @@ function CardContent({
overlayStyle, overlayStyle,
handleButtonPress, handleButtonPress,
onFeedbackPress, onFeedbackPress,
translateX,
translateY,
}: { }: {
question: Props['question']; question: Props['question'];
overlayStyle: (direction: "left" | "right" | "up" | "down") => any; overlayStyle: (direction: "left" | "right" | "up" | "down") => any;
handleButtonPress: (direction: "left" | "right" | "up") => void; handleButtonPress: (direction: "left" | "right" | "up") => void;
onFeedbackPress: () => void; onFeedbackPress: () => void;
translateX: Animated.SharedValue<number>;
translateY: Animated.SharedValue<number>;
}) { }) {
const isHighIntensity = question.intensity >= 3;
// Badge scale animation based on swipe progress
const badgeScaleStyle = (direction: "left" | "right" | "up" | "down") =>
useAnimatedStyle(() => {
let progress = 0;
if (direction === "right") {
progress = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1], Extrapolate.CLAMP);
} else if (direction === "left") {
progress = interpolate(translateX.value, [-SWIPE_THRESHOLD, 0], [1, 0], Extrapolate.CLAMP);
} else if (direction === "up") {
progress = interpolate(translateY.value, [-SWIPE_THRESHOLD, 0], [1, 0], Extrapolate.CLAMP);
} else {
progress = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 1], Extrapolate.CLAMP);
}
return {
transform: [{ scale: interpolate(progress, [0, 0.5, 1], [0.8, 1, 1.1]) }],
};
});
return ( return (
<> <>
{/* Top highlight */} {/* Top highlight */}
@@ -219,6 +286,24 @@ function CardContent({
pointerEvents="none" pointerEvents="none"
/> />
{/* Left edge highlight */}
<LinearGradient
colors={['rgba(255,255,255,0.08)', 'transparent']}
style={styles.edgeHighlightLeft}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
pointerEvents="none"
/>
{/* Right edge highlight */}
<LinearGradient
colors={['transparent', 'rgba(255,255,255,0.08)']}
style={styles.edgeHighlightRight}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
pointerEvents="none"
/>
{/* Feedback Button */} {/* Feedback Button */}
<View style={styles.feedbackButton}> <View style={styles.feedbackButton}>
<Ionicons <Ionicons
@@ -229,31 +314,53 @@ function CardContent({
/> />
</View> </View>
{/* Overlays */} {/* Overlays with gradient badges */}
<Animated.View style={[styles.overlay, overlayStyle("right")]} pointerEvents="none"> <Animated.View style={[styles.overlay, overlayStyle("right")]} pointerEvents="none">
<View style={[styles.overlayBadge, { backgroundColor: colors.success }]}> <Animated.View style={[styles.overlayBadge, badgeScaleStyle("right")]}>
<LinearGradient
colors={gradients.success as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Text style={styles.overlayText}>YES</Text> <Text style={styles.overlayText}>YES</Text>
</View> </Animated.View>
</Animated.View> </Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("left")]} pointerEvents="none"> <Animated.View style={[styles.overlay, overlayStyle("left")]} pointerEvents="none">
<View style={[styles.overlayBadge, { backgroundColor: colors.error }]}> <Animated.View style={[styles.overlayBadge, badgeScaleStyle("left")]}>
<LinearGradient
colors={gradients.error as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Text style={styles.overlayText}>NO</Text> <Text style={styles.overlayText}>NO</Text>
</View> </Animated.View>
</Animated.View> </Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("up")]} pointerEvents="none"> <Animated.View style={[styles.overlay, overlayStyle("up")]} pointerEvents="none">
<View style={[styles.overlayBadge, { backgroundColor: colors.warning }]}> <Animated.View style={[styles.overlayBadge, badgeScaleStyle("up")]}>
<LinearGradient
colors={gradients.warning as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Text style={styles.overlayText}>MAYBE</Text> <Text style={styles.overlayText}>MAYBE</Text>
</View> </Animated.View>
</Animated.View> </Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("down")]} pointerEvents="none"> <Animated.View style={[styles.overlay, overlayStyle("down")]} pointerEvents="none">
<View style={[styles.overlayBadge, { backgroundColor: colors.textTertiary }]}> <Animated.View style={[styles.overlayBadge, badgeScaleStyle("down")]}>
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.textTertiary }]} />
<Text style={styles.overlayText}>SKIP</Text> <Text style={styles.overlayText}>SKIP</Text>
</View> </Animated.View>
</Animated.View> </Animated.View>
{/* Content */} {/* Content */}
<View style={styles.content}> <View style={styles.content}>
<View style={styles.intensityContainer}> <View style={[
styles.intensityContainer,
isHighIntensity && styles.intensityContainerHigh
]}>
{[...Array(question.intensity)].map((_, i) => ( {[...Array(question.intensity)].map((_, i) => (
<Ionicons key={i} name="flame" size={16} color={colors.primary} /> <Ionicons key={i} name="flame" size={16} color={colors.primary} />
))} ))}
@@ -299,26 +406,57 @@ function ActionButton({
icon: string; icon: string;
small?: boolean; small?: boolean;
}) { }) {
const buttonScale = useSharedValue(1);
const buttonGlow = useSharedValue(0);
const handlePressIn = () => {
buttonScale.value = withSpring(0.92, animations.spring);
buttonGlow.value = withTiming(1, { duration: 150 });
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
buttonGlow.value = withTiming(0, { duration: 200 });
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
shadowOpacity: interpolate(buttonGlow.value, [0, 1], [0.3, 0.6]),
shadowRadius: interpolate(buttonGlow.value, [0, 1], [8, 16]),
}));
return ( return (
<TouchableOpacity onPress={onPress} activeOpacity={0.8}> <TouchableOpacity
<LinearGradient onPress={onPress}
colors={buttonColors} onPressIn={handlePressIn}
style={[ onPressOut={handlePressOut}
styles.actionButton, activeOpacity={1}
small && styles.actionButtonSmall, >
shadows.md, <Animated.View style={[
]} styles.actionButton,
start={{ x: 0, y: 0 }} small && styles.actionButtonSmall,
end={{ x: 1, y: 1 }} buttonAnimatedStyle,
> { shadowColor: buttonColors[0] }
<View style={styles.buttonHighlight} /> ]}>
<LinearGradient
colors={buttonColors}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<LinearGradient
colors={['rgba(255,255,255,0.25)', 'rgba(255,255,255,0.05)']}
style={styles.buttonHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<Ionicons <Ionicons
name={icon as any} name={icon as any}
size={small ? 20 : 28} size={small ? 20 : 28}
color={colors.text} color={colors.text}
style={styles.buttonIcon} style={styles.buttonIcon}
/> />
</LinearGradient> </Animated.View>
</TouchableOpacity> </TouchableOpacity>
); );
} }
@@ -358,6 +496,30 @@ const styles = StyleSheet.create({
right: 0, right: 0,
height: 80, height: 80,
}, },
edgeHighlightLeft: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: 60,
},
edgeHighlightRight: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
width: 60,
},
innerBorder: {
position: 'absolute',
top: 2,
left: 2,
right: 2,
bottom: 2,
borderRadius: radius.xxl - 2,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.06)',
},
feedbackButton: { feedbackButton: {
position: 'absolute', position: 'absolute',
top: spacing.md, top: spacing.md,
@@ -388,6 +550,11 @@ const styles = StyleSheet.create({
borderRadius: radius.full, borderRadius: radius.full,
gap: 2, gap: 2,
}, },
intensityContainerHigh: {
borderWidth: 1,
borderColor: colors.primary,
...shadows.glow(colors.primaryGlow),
},
text: { text: {
...typography.title2, ...typography.title2,
color: colors.text, color: colors.text,
@@ -414,6 +581,7 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
borderRadius: radius.md, borderRadius: radius.md,
transform: [{ rotate: "-15deg" }], transform: [{ rotate: "-15deg" }],
overflow: 'hidden',
}, },
overlayText: { overlayText: {
fontSize: 36, fontSize: 36,
@@ -446,7 +614,8 @@ const styles = StyleSheet.create({
left: 0, left: 0,
right: 0, right: 0,
height: "50%", height: "50%",
backgroundColor: "rgba(255, 255, 255, 0.15)", borderTopLeftRadius: 32,
borderTopRightRadius: 32,
}, },
buttonIcon: { buttonIcon: {
zIndex: 1, zIndex: 1,
+11 -3
View File
@@ -145,6 +145,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
<Animated.View <Animated.View
style={[ style={[
styles.card, styles.card,
webStyles.card as any,
{ {
width: cardWidth, width: cardWidth,
transform: [ transform: [
@@ -206,7 +207,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
<Ionicons key={i} name="flame" size={16} color="#e94560" /> <Ionicons key={i} name="flame" size={16} color="#e94560" />
))} ))}
</View> </View>
<Text style={[styles.text, question.is_two_part ? styles.twoPartText : null]}>{question.text}</Text> <Text style={[styles.text, question.partner_text ? styles.twoPartText : null]}>{question.text}</Text>
</View> </View>
<View style={styles.footer}> <View style={styles.footer}>
@@ -218,6 +219,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
hoveredButton === "no" && styles.buttonHovered, hoveredButton === "no" && styles.buttonHovered,
]} ]}
onPress={() => onSwipe("left")} onPress={() => onSwipe("left")}
// @ts-expect-error - web-only props
onMouseEnter={() => setHoveredButton("no")} onMouseEnter={() => setHoveredButton("no")}
onMouseLeave={() => setHoveredButton(null)} onMouseLeave={() => setHoveredButton(null)}
activeOpacity={0.7} activeOpacity={0.7}
@@ -233,6 +235,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
hoveredButton === "maybe" && styles.buttonHovered, hoveredButton === "maybe" && styles.buttonHovered,
]} ]}
onPress={() => onSwipe("up")} onPress={() => onSwipe("up")}
// @ts-expect-error - web-only props
onMouseEnter={() => setHoveredButton("maybe")} onMouseEnter={() => setHoveredButton("maybe")}
onMouseLeave={() => setHoveredButton(null)} onMouseLeave={() => setHoveredButton(null)}
activeOpacity={0.7} activeOpacity={0.7}
@@ -248,6 +251,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
hoveredButton === "yes" && styles.buttonHovered, hoveredButton === "yes" && styles.buttonHovered,
]} ]}
onPress={() => onSwipe("right")} onPress={() => onSwipe("right")}
// @ts-expect-error - web-only props
onMouseEnter={() => setHoveredButton("yes")} onMouseEnter={() => setHoveredButton("yes")}
onMouseLeave={() => setHoveredButton(null)} onMouseLeave={() => setHoveredButton(null)}
activeOpacity={0.7} activeOpacity={0.7}
@@ -269,6 +273,12 @@ export default function SwipeCard({ question, onSwipe }: Props) {
); );
} }
// Separate web-only styles that TypeScript doesn't recognize
const webStyles = {
card: { cursor: "grab" as const },
actionButton: { transition: "transform 0.2s ease, box-shadow 0.2s ease" },
};
const styles = StyleSheet.create({ const styles = StyleSheet.create({
card: { card: {
position: "absolute", position: "absolute",
@@ -287,7 +297,6 @@ const styles = StyleSheet.create({
shadowRadius: 4.65, shadowRadius: 4.65,
elevation: 8, elevation: 8,
overflow: "hidden", overflow: "hidden",
cursor: "grab",
}, },
feedbackButton: { feedbackButton: {
position: 'absolute', position: 'absolute',
@@ -402,7 +411,6 @@ const styles = StyleSheet.create({
elevation: 8, elevation: 8,
borderWidth: 2, borderWidth: 2,
overflow: "hidden", overflow: "hidden",
transition: "transform 0.2s ease, box-shadow 0.2s ease",
}, },
noButton: { noButton: {
backgroundColor: "#E74C3C", backgroundColor: "#E74C3C",
+1 -3
View File
@@ -20,9 +20,7 @@ export async function initAnalytics() {
posthog = new PostHog(apiKey, { posthog = new PostHog(apiKey, {
host, host,
// Disable automatic capture in production for privacy // Disable automatic capture in production for privacy
captureApplicationLifecycleEvents: true, captureAppLifecycleEvents: true,
captureDeepLinks: true,
recordScreenViews: false, // We'll track manually for more control
}); });
isInitialized = true; isInitialized = true;
+33 -27
View File
@@ -378,17 +378,23 @@ export type Database = {
type PublicSchema = Database[Extract<keyof Database, "public">] type PublicSchema = Database[Extract<keyof Database, "public">]
// Helper type to get schema keys excluding internal keys
type SchemaName = Exclude<keyof Database, "__InternalSupabase">
// Helper to safely get schema
type GetSchema<S extends SchemaName> = Database[S]
export type Tables< export type Tables<
PublicTableNameOrOptions extends PublicTableNameOrOptions extends
| keyof (PublicSchema["Tables"] & PublicSchema["Views"]) | keyof (PublicSchema["Tables"] & PublicSchema["Views"])
| { schema: keyof Database }, | { schema: SchemaName },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database } TableName extends PublicTableNameOrOptions extends { schema: SchemaName }
? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] & ? keyof (GetSchema<PublicTableNameOrOptions["schema"]>["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"]) GetSchema<PublicTableNameOrOptions["schema"]>["Views"])
: never = never, : never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database } > = PublicTableNameOrOptions extends { schema: SchemaName }
? (Database[PublicTableNameOrOptions["schema"]]["Tables"] & ? (GetSchema<PublicTableNameOrOptions["schema"]>["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends { GetSchema<PublicTableNameOrOptions["schema"]>["Views"])[TableName] extends {
Row: infer R Row: infer R
} }
? R ? R
@@ -406,12 +412,12 @@ export type Tables<
export type TablesInsert< export type TablesInsert<
PublicTableNameOrOptions extends PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"] | keyof PublicSchema["Tables"]
| { schema: keyof Database }, | { schema: SchemaName },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database } TableName extends PublicTableNameOrOptions extends { schema: SchemaName }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] ? keyof GetSchema<PublicTableNameOrOptions["schema"]>["Tables"]
: never = never, : never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database } > = PublicTableNameOrOptions extends { schema: SchemaName }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { ? GetSchema<PublicTableNameOrOptions["schema"]>["Tables"][TableName] extends {
Insert: infer I Insert: infer I
} }
? I ? I
@@ -427,12 +433,12 @@ export type TablesInsert<
export type TablesUpdate< export type TablesUpdate<
PublicTableNameOrOptions extends PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"] | keyof PublicSchema["Tables"]
| { schema: keyof Database }, | { schema: SchemaName },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database } TableName extends PublicTableNameOrOptions extends { schema: SchemaName }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] ? keyof GetSchema<PublicTableNameOrOptions["schema"]>["Tables"]
: never = never, : never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database } > = PublicTableNameOrOptions extends { schema: SchemaName }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { ? GetSchema<PublicTableNameOrOptions["schema"]>["Tables"][TableName] extends {
Update: infer U Update: infer U
} }
? U ? U
@@ -448,12 +454,12 @@ export type TablesUpdate<
export type Enums< export type Enums<
PublicEnumNameOrOptions extends PublicEnumNameOrOptions extends
| keyof PublicSchema["Enums"] | keyof PublicSchema["Enums"]
| { schema: keyof Database }, | { schema: SchemaName },
EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } EnumName extends PublicEnumNameOrOptions extends { schema: SchemaName }
? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"] ? keyof GetSchema<PublicEnumNameOrOptions["schema"]>["Enums"]
: never = never, : never = never,
> = PublicEnumNameOrOptions extends { schema: keyof Database } > = PublicEnumNameOrOptions extends { schema: SchemaName }
? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName] ? GetSchema<PublicEnumNameOrOptions["schema"]>["Enums"][EnumName]
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
? PublicSchema["Enums"][PublicEnumNameOrOptions] ? PublicSchema["Enums"][PublicEnumNameOrOptions]
: never : never
@@ -461,14 +467,14 @@ export type Enums<
export type CompositeTypes< export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends PublicCompositeTypeNameOrOptions extends
| keyof PublicSchema["CompositeTypes"] | keyof PublicSchema["CompositeTypes"]
| { schema: keyof Database }, | { schema: SchemaName },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof Database schema: SchemaName
} }
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] ? keyof GetSchema<PublicCompositeTypeNameOrOptions["schema"]>["CompositeTypes"]
: never = never, : never = never,
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database } > = PublicCompositeTypeNameOrOptions extends { schema: SchemaName }
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] ? GetSchema<PublicCompositeTypeNameOrOptions["schema"]>["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"] : PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"]
? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] ? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never : never
+2 -1
View File
@@ -2,7 +2,8 @@
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"lib": [ "lib": [
"ES2020" "ES2020",
"DOM"
], ],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
+17
View File
@@ -68,6 +68,7 @@
"@expo/ngrok": "^4.1.0", "@expo/ngrok": "^4.1.0",
"@expo/vector-icons": "~14.0.4", "@expo/vector-icons": "~14.0.4",
"@react-native-async-storage/async-storage": "1.23.1", "@react-native-async-storage/async-storage": "1.23.1",
"@react-native-google-signin/google-signin": "^16.1.1",
"@react-navigation/bottom-tabs": "^7.0.0", "@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0", "@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0", "@react-navigation/native-stack": "^7.0.0",
@@ -5891,6 +5892,22 @@
"react-native": "^0.0.0-0 || >=0.60 <1.0" "react-native": "^0.0.0-0 || >=0.60 <1.0"
} }
}, },
"node_modules/@react-native-google-signin/google-signin": {
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/@react-native-google-signin/google-signin/-/google-signin-16.1.1.tgz",
"integrity": "sha512-lcHBnZ7uvCJiWtGooKOklo/4okqszWvJ0BatW1UaIe+ynmpVpp1lyJkvv1Mj08d39k4soaWuhZVNKjD/RFL34Q==",
"license": "MIT",
"peerDependencies": {
"expo": ">=52.0.40",
"react": "*",
"react-native": "*"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
}
}
},
"node_modules/@react-native/assets-registry": { "node_modules/@react-native/assets-registry": {
"version": "0.76.9", "version": "0.76.9",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz",