This commit is contained in:
Jamie Brunton committed 2025-12-31 14:54:28 +00:00
1 parent e7379ccdb1
commit d18f9d32b0
49 files changed
+3739 -665

No files matched your search

+18 -1
View File
@@ -63,7 +63,7 @@ export function FeedbackModal({ visible, onClose }: FeedbackModalProps) {
const handlePickScreenshot = async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
allowsEditing: false,
quality: 0.7,
});
@@ -178,6 +178,16 @@ export function FeedbackModal({ visible, onClose }: FeedbackModalProps) {
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.content}>
{/* Close Button */}
<TouchableOpacity
style={styles.closeButton}
onPress={handleClose}
disabled={isSubmitting}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
@@ -375,6 +385,13 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: colors.glass.border,
},
closeButton: {
position: 'absolute',
top: spacing.md,
right: spacing.md,
zIndex: 1,
padding: spacing.xs,
},
scrollContent: {
flexGrow: 1,
},
+105 -42
View File
@@ -6,6 +6,8 @@ import Animated, {
useAnimatedStyle,
withRepeat,
withTiming,
withSequence,
withDelay,
interpolate,
Easing,
} from 'react-native-reanimated';
@@ -19,44 +21,43 @@ interface ShimmerEffectProps {
enabled?: boolean;
}
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
export function ShimmerEffect({
children,
style,
shimmerColor = colors.premium.gold,
duration = 2500,
duration = 4000,
enabled = true,
}: ShimmerEffectProps) {
const shimmerTranslate = useSharedValue(-1);
const glowIntensity = useSharedValue(0);
useEffect(() => {
if (enabled) {
shimmerTranslate.value = withRepeat(
withTiming(1, {
duration,
easing: Easing.inOut(Easing.ease),
}),
-1, // infinite repeat
false // don't reverse
// Gentle breathing glow - slow fade in, hold, slow fade out, pause
glowIntensity.value = withRepeat(
withSequence(
withTiming(1, {
duration: duration * 0.4,
easing: Easing.out(Easing.cubic),
}),
withDelay(
duration * 0.2,
withTiming(0, {
duration: duration * 0.4,
easing: Easing.in(Easing.cubic),
})
)
),
-1,
false
);
} else {
shimmerTranslate.value = -1;
glowIntensity.value = 0;
}
}, [enabled, duration]);
const shimmerStyle = useAnimatedStyle(() => {
const translateX = interpolate(
shimmerTranslate.value,
[-1, 1],
[-200, 200]
);
const glowStyle = useAnimatedStyle(() => {
return {
transform: [
{ translateX },
{ rotate: '25deg' },
],
opacity: interpolate(glowIntensity.value, [0, 1], [0, 0.6]),
};
});
@@ -64,18 +65,47 @@ export function ShimmerEffect({
<View style={[styles.container, style]}>
{children}
{enabled && (
<Animated.View style={[styles.shimmerContainer, shimmerStyle]}>
<Animated.View style={[styles.glowContainer, glowStyle]} pointerEvents="none">
{/* Top edge glow */}
<LinearGradient
colors={[
'transparent',
`${shimmerColor}40`, // 25% opacity
`${shimmerColor}60`, // 37% opacity
`${shimmerColor}40`, // 25% opacity
'transparent',
]}
colors={[`${shimmerColor}30`, 'transparent']}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
style={styles.edgeGlowTop}
/>
{/* Bottom edge glow */}
<LinearGradient
colors={['transparent', `${shimmerColor}20`]}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
style={styles.edgeGlowBottom}
/>
{/* Left edge glow */}
<LinearGradient
colors={[`${shimmerColor}25`, 'transparent']}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
style={styles.shimmerGradient}
style={styles.edgeGlowLeft}
/>
{/* Right edge glow */}
<LinearGradient
colors={['transparent', `${shimmerColor}25`]}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
style={styles.edgeGlowRight}
/>
{/* Corner accents for refined look */}
<LinearGradient
colors={[`${shimmerColor}35`, 'transparent']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.cornerTopLeft}
/>
<LinearGradient
colors={[`${shimmerColor}35`, 'transparent']}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.cornerTopRight}
/>
</Animated.View>
)}
@@ -87,17 +117,50 @@ const styles = StyleSheet.create({
container: {
overflow: 'hidden',
},
shimmerContainer: {
position: 'absolute',
top: -50,
bottom: -50,
width: 100,
left: '50%',
marginLeft: -50,
glowContainer: {
...StyleSheet.absoluteFillObject,
},
shimmerGradient: {
flex: 1,
width: '100%',
edgeGlowTop: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 24,
},
edgeGlowBottom: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 16,
},
edgeGlowLeft: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: 16,
},
edgeGlowRight: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
width: 16,
},
cornerTopLeft: {
position: 'absolute',
top: 0,
left: 0,
width: 40,
height: 40,
},
cornerTopRight: {
position: 'absolute',
top: 0,
right: 0,
width: 40,
height: 40,
},
});
+107
View File
@@ -0,0 +1,107 @@
import { router } from "expo-router";
import { supabase } from "./supabase";
import { useAuthStore } from "../store";
/**
* Handles authentication errors from edge function calls.
*
* When a 401 error occurs, this function:
* 1. Attempts to refresh the session
* 2. If refresh fails (session was deleted server-side), signs the user out and navigates to login
*
* @returns true if the error was handled and user should retry, false if signed out
*/
export async function handleAuthError(error: any): Promise<boolean> {
// Check if this is a 401 authentication error
const is401 = error?.status === 401 ||
error?.message?.includes("401") ||
error?.message?.includes("session") ||
error?.message?.includes("unauthorized");
if (!is401) {
return false;
}
console.log("[AuthErrorHandler] Detected auth error, attempting session refresh");
try {
// Try to refresh the session
const { data, error: refreshError } = await supabase.auth.refreshSession();
if (refreshError || !data.session) {
console.log("[AuthErrorHandler] Session refresh failed, signing out:", refreshError?.message);
// Session is invalid - sign the user out and navigate to login
await useAuthStore.getState().signOut();
// Explicitly navigate to login to ensure user doesn't get stuck
router.replace("/(auth)/login");
return false;
}
console.log("[AuthErrorHandler] Session refreshed successfully");
return true;
} catch (e) {
console.error("[AuthErrorHandler] Error during refresh:", e);
await useAuthStore.getState().signOut();
router.replace("/(auth)/login");
return false;
}
}
/**
* Wrapper for edge function invocation with automatic auth error handling.
*
* @param functionName - Name of the edge function to invoke
* @param options - Options including body and headers
* @param retryOnAuthError - Whether to retry after refreshing auth (default: true)
* @returns The result of the function invocation
*/
export async function invokeWithAuthRetry(
functionName: string,
options: { body?: any; headers?: Record<string, string> } = {},
retryOnAuthError = true
): Promise<{ data: any; error: any }> {
// Get current session
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
console.error("[AuthErrorHandler] No valid session for function call");
return { data: null, error: { message: "No valid session" } };
}
// Make the request
const result = await supabase.functions.invoke(functionName, {
body: options.body,
headers: {
...options.headers,
Authorization: `Bearer ${session.access_token}`,
},
});
// Check for auth error
if (result.error) {
const is401 = result.error?.status === 401 ||
result.error?.message?.includes("401") ||
result.error?.context?.status === 401;
if (is401 && retryOnAuthError) {
console.log("[AuthErrorHandler] Got 401, attempting refresh and retry");
const canRetry = await handleAuthError(result.error);
if (canRetry) {
// Get new session and retry
const { data: { session: newSession } } = await supabase.auth.getSession();
if (newSession?.access_token) {
return supabase.functions.invoke(functionName, {
body: options.body,
headers: {
...options.headers,
Authorization: `Bearer ${newSession.access_token}`,
},
});
}
}
}
}
return result;
}
+20 -1
View File
@@ -34,6 +34,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
fetchUser: async () => {
try {
// First check if we have a session in storage
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user) {
@@ -41,10 +42,28 @@ export const useAuthStore = create<AuthState>((set, get) => ({
return;
}
// Validate the session by fetching the user from the server
// This will fail if the session was deleted server-side
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
if (authError || !authUser) {
console.log("[Auth] Session invalid, signing out:", authError?.message);
// Session is invalid - clear everything
set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false });
// Clear other stores
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
// Sign out from Supabase (clears storage)
await supabase.auth.signOut();
return;
}
const { data: profile } = await supabase
.from("profiles")
.select("*")
.eq("id", session.user.id)
.eq("id", authUser.id)
.maybeSingle();
set({