mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(mobile): surface pending questions
Add a conditional Home cue for partner-answered questions and route both the card and catch-up reminders into the pending answer flow. Track prompt engagement and cover hidden, visible, CTA, and notification behavior.
This commit is contained in:
1 parent
9da44217f7
commit
2bc69c2790
7 files changed
+383
-13
No files matched your search
@@ -1,15 +1,17 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native';
|
import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native';
|
||||||
import { useFocusEffect } from 'expo-router';
|
import { useFocusEffect, useRouter } from 'expo-router';
|
||||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store';
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { useAuthStore, useMatchStore, usePacksStore, useSubscriptionStore } from '../../src/store';
|
||||||
import { GradientBackground } from '../../src/components/ui';
|
import { GradientBackground } from '../../src/components/ui';
|
||||||
import { Paywall } from '../../src/components/paywall';
|
import { Paywall } from '../../src/components/paywall';
|
||||||
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery';
|
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard } from '../../src/components/discovery';
|
||||||
import { StreakDisplay } from '../../src/components/StreakDisplay';
|
import { StreakDisplay } from '../../src/components/StreakDisplay';
|
||||||
import { colors, spacing, typography, radius } from '../../src/theme';
|
import { Events } from '../../src/lib/analytics';
|
||||||
|
import { colors, spacing, typography } from '../../src/theme';
|
||||||
import type { QuestionPack, Category } from '../../src/types';
|
import type { QuestionPack, Category } from '../../src/types';
|
||||||
|
|
||||||
const MAX_CONTENT_WIDTH = 600;
|
const MAX_CONTENT_WIDTH = 600;
|
||||||
@@ -69,14 +71,21 @@ function groupPacksByCategory(
|
|||||||
export default function DiscoveryScreen() {
|
export default function DiscoveryScreen() {
|
||||||
const { user, partner, couple } = useAuthStore();
|
const { user, partner, couple } = useAuthStore();
|
||||||
const { packs, categories, fetchPacks, ensurePacksLoaded, getPackProgress } = usePacksStore();
|
const { packs, categories, fetchPacks, ensurePacksLoaded, getPackProgress } = usePacksStore();
|
||||||
|
const { pendingQuestions, fetchPendingQuestions } = useMatchStore();
|
||||||
const { subscription } = useSubscriptionStore();
|
const { subscription } = useSubscriptionStore();
|
||||||
|
const router = useRouter();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const [showPaywall, setShowPaywall] = useState(false);
|
const [showPaywall, setShowPaywall] = useState(false);
|
||||||
const [headerHeight, setHeaderHeight] = useState(130);
|
const [headerHeight, setHeaderHeight] = useState(130);
|
||||||
|
const lastPromptImpression = useRef<string | null>(null);
|
||||||
|
|
||||||
// Check all sources of premium access: user flag, partner flag, or active subscription
|
// Check all sources of premium access: user flag, partner flag, or active subscription
|
||||||
const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser;
|
const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser;
|
||||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||||
|
const pendingQuestionIds = useMemo(
|
||||||
|
() => pendingQuestions.map(question => question.id).join(','),
|
||||||
|
[pendingQuestions],
|
||||||
|
);
|
||||||
|
|
||||||
// Load the catalog once. Returning to this tab shows what is cached; answering a
|
// Load the catalog once. Returning to this tab shows what is cached; answering a
|
||||||
// question invalidates it, and StreakDisplay owns the streak, so neither needs a
|
// question invalidates it, and StreakDisplay owns the streak, so neither needs a
|
||||||
@@ -84,7 +93,17 @@ export default function DiscoveryScreen() {
|
|||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
void ensurePacksLoaded();
|
void ensurePacksLoaded();
|
||||||
}, [ensurePacksLoaded])
|
void fetchPendingQuestions();
|
||||||
|
|
||||||
|
if (pendingQuestionIds && lastPromptImpression.current !== pendingQuestionIds) {
|
||||||
|
Events.pendingQuestionsPromptViewed(pendingQuestions.length);
|
||||||
|
lastPromptImpression.current = pendingQuestionIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
lastPromptImpression.current = null;
|
||||||
|
};
|
||||||
|
}, [ensurePacksLoaded, fetchPendingQuestions, pendingQuestionIds, pendingQuestions.length])
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group packs by category
|
// Group packs by category
|
||||||
@@ -96,6 +115,18 @@ export default function DiscoveryScreen() {
|
|||||||
// Check if user has no packs enabled (empty state)
|
// Check if user has no packs enabled (empty state)
|
||||||
const hasNoPacks = packs.length === 0;
|
const hasNoPacks = packs.length === 0;
|
||||||
|
|
||||||
|
const handlePendingQuestionsPress = useCallback(() => {
|
||||||
|
const firstQuestion = pendingQuestions[0];
|
||||||
|
if (!firstQuestion) return;
|
||||||
|
|
||||||
|
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
Events.pendingQuestionsPromptTapped(pendingQuestions.length);
|
||||||
|
router.push({
|
||||||
|
pathname: '/(app)/swipe',
|
||||||
|
params: { mode: 'pending', startQuestionId: firstQuestion.question.id },
|
||||||
|
});
|
||||||
|
}, [pendingQuestions, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
<View
|
<View
|
||||||
@@ -117,16 +148,23 @@ export default function DiscoveryScreen() {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
|
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
|
||||||
|
<PendingQuestionsCard
|
||||||
|
questions={pendingQuestions}
|
||||||
|
partnerName={partner?.name}
|
||||||
|
onPress={handlePendingQuestionsPress}
|
||||||
|
delay={50}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Shared streak - hidden entirely for couples with nothing running */}
|
{/* Shared streak - hidden entirely for couples with nothing running */}
|
||||||
<View style={styles.streakSlot}>
|
<View style={styles.streakSlot}>
|
||||||
<StreakDisplay delay={50} />
|
<StreakDisplay delay={100} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Live Draw Activity Tile */}
|
{/* Live Draw Activity Tile */}
|
||||||
<LiveDrawTile delay={100} />
|
<LiveDrawTile delay={150} />
|
||||||
|
|
||||||
{/* Dares */}
|
{/* Dares */}
|
||||||
<DaresTile delay={150} />
|
<DaresTile delay={200} />
|
||||||
|
|
||||||
{/* Content Rows by Category */}
|
{/* Content Rows by Category */}
|
||||||
{packsByCategory.map((group, index) => (
|
{packsByCategory.map((group, index) => (
|
||||||
@@ -136,7 +174,7 @@ export default function DiscoveryScreen() {
|
|||||||
packs={group.packs}
|
packs={group.packs}
|
||||||
isPremiumUser={isPremiumUser}
|
isPremiumUser={isPremiumUser}
|
||||||
categoryId={group.category.id}
|
categoryId={group.category.id}
|
||||||
delay={200 + index * 100}
|
delay={250 + index * 100}
|
||||||
getPackProgress={getPackProgress}
|
getPackProgress={getPackProgress}
|
||||||
onShowPaywall={() => setShowPaywall(true)}
|
onShowPaywall={() => setShowPaywall(true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { fireEvent, render } from '@/test/test-utils';
|
||||||
|
import { PendingQuestionsCard, formatWaitingSince } from '@/components/discovery/PendingQuestionsCard';
|
||||||
|
import type { PendingQuestion } from '@/store';
|
||||||
|
|
||||||
|
const pendingQuestion = (id: string, partnerAnsweredAt: string): PendingQuestion => ({
|
||||||
|
id: `response-${id}`,
|
||||||
|
partnerAnsweredAt,
|
||||||
|
question: {
|
||||||
|
id,
|
||||||
|
pack_id: 'pack-1',
|
||||||
|
text: `Question ${id}`,
|
||||||
|
intensity: 1,
|
||||||
|
created_at: '2026-08-01T00:00:00.000Z',
|
||||||
|
pack: { id: 'pack-1', name: 'Connection', icon: 'heart' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PendingQuestionsCard', () => {
|
||||||
|
it('stays out of Home when there is nothing to answer', () => {
|
||||||
|
const { queryByTestId } = render(
|
||||||
|
<PendingQuestionsCard questions={[]} partnerName="Sam" onPress={jest.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(queryByTestId('pending-questions-card')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the partner, shows the count, and preserves answer privacy', () => {
|
||||||
|
const questions = [
|
||||||
|
pendingQuestion('q1', '2026-08-29T09:00:00.000Z'),
|
||||||
|
pendingQuestion('q2', '2026-08-29T10:00:00.000Z'),
|
||||||
|
];
|
||||||
|
const { getByText, getByLabelText } = render(
|
||||||
|
<PendingQuestionsCard questions={questions} partnerName="Sam" onPress={jest.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Sam has answered 2 questions')).toBeTruthy();
|
||||||
|
expect(getByText('Answer yours to see where you match. Their answers stay private until you respond.')).toBeTruthy();
|
||||||
|
expect(getByLabelText('2 questions waiting')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the answer action from its accessible CTA', () => {
|
||||||
|
const onPress = jest.fn();
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<PendingQuestionsCard
|
||||||
|
questions={[pendingQuestion('q1', '2026-08-29T09:00:00.000Z')]}
|
||||||
|
partnerName="Sam"
|
||||||
|
onPress={onPress}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.press(getByTestId('pending-questions-answer'));
|
||||||
|
expect(onPress).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatWaitingSince', () => {
|
||||||
|
const now = new Date('2026-08-29T12:00:00.000Z');
|
||||||
|
|
||||||
|
it('formats useful compact waiting ages', () => {
|
||||||
|
expect(formatWaitingSince('2026-08-29T11:59:30.000Z', now)).toBe('just now');
|
||||||
|
expect(formatWaitingSince('2026-08-29T11:42:00.000Z', now)).toBe('18m ago');
|
||||||
|
expect(formatWaitingSince('2026-08-29T09:00:00.000Z', now)).toBe('3h ago');
|
||||||
|
expect(formatWaitingSince('2026-08-27T12:00:00.000Z', now)).toBe('2d ago');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { handleNotificationResponse } from '@/lib/notifications';
|
||||||
|
import { useMatchStore } from '@/store/matchStore';
|
||||||
|
|
||||||
|
jest.mock('expo-router', () => ({
|
||||||
|
router: { push: jest.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('expo-notifications', () => ({
|
||||||
|
setNotificationHandler: jest.fn(),
|
||||||
|
addNotificationResponseReceivedListener: jest.fn(),
|
||||||
|
getLastNotificationResponseAsync: jest.fn(),
|
||||||
|
getPermissionsAsync: jest.fn(),
|
||||||
|
requestPermissionsAsync: jest.fn(),
|
||||||
|
getExpoPushTokenAsync: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/lib/crashlytics', () => ({
|
||||||
|
captureError: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const notificationResponse = (type: string) => ({
|
||||||
|
notification: {
|
||||||
|
request: {
|
||||||
|
content: { data: { type } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as never;
|
||||||
|
|
||||||
|
describe('handleNotificationResponse', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useMatchStore.setState({ currentView: 'active' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the answer queue for a catch-up reminder', () => {
|
||||||
|
handleNotificationResponse(notificationResponse('catchup_reminder'));
|
||||||
|
|
||||||
|
expect(useMatchStore.getState().currentView).toBe('pending');
|
||||||
|
expect(router.push).toHaveBeenCalledWith('/(app)/swipe?mode=pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores unknown notification types', () => {
|
||||||
|
handleNotificationResponse(notificationResponse('unknown'));
|
||||||
|
|
||||||
|
expect(router.push).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
|
import type { PendingQuestion } from '../../store';
|
||||||
|
import { colors, gradients, radius, shadows, spacing, typography } from '../../theme';
|
||||||
|
|
||||||
|
interface PendingQuestionsCardProps {
|
||||||
|
questions: PendingQuestion[];
|
||||||
|
partnerName?: string | null;
|
||||||
|
onPress: () => void;
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWaitingSince(dateString: string, now = new Date()): string {
|
||||||
|
const answeredAt = new Date(dateString);
|
||||||
|
const diffMs = Math.max(0, now.getTime() - answeredAt.getTime());
|
||||||
|
const minutes = Math.floor(diffMs / 60_000);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (minutes < 1) return 'just now';
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
return answeredAt.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PendingQuestionsCard({
|
||||||
|
questions,
|
||||||
|
partnerName,
|
||||||
|
onPress,
|
||||||
|
delay = 0,
|
||||||
|
}: PendingQuestionsCardProps) {
|
||||||
|
const oldestQuestion = useMemo(
|
||||||
|
() => questions.reduce<PendingQuestion | null>((oldest, question) => {
|
||||||
|
if (!oldest) return question;
|
||||||
|
return new Date(question.partnerAnsweredAt) < new Date(oldest.partnerAnsweredAt)
|
||||||
|
? question
|
||||||
|
: oldest;
|
||||||
|
}, null),
|
||||||
|
[questions],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!oldestQuestion) return null;
|
||||||
|
|
||||||
|
const count = questions.length;
|
||||||
|
const subject = partnerName?.trim() || 'Your partner';
|
||||||
|
const questionLabel = count === 1 ? 'question' : 'questions';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(delay).duration(450).springify()}
|
||||||
|
style={styles.container}
|
||||||
|
testID="pending-questions-card"
|
||||||
|
>
|
||||||
|
<View style={[styles.card, shadows.md]}>
|
||||||
|
<View style={styles.accent} />
|
||||||
|
|
||||||
|
<View style={styles.labelRow}>
|
||||||
|
<View style={styles.labelGroup}>
|
||||||
|
<View style={styles.statusDot} />
|
||||||
|
<Text style={styles.label}>Your turn</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.countBadge} accessibilityLabel={`${count} ${questionLabel} waiting`}>
|
||||||
|
<Text style={styles.countText}>{count}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.title}>
|
||||||
|
{subject} has answered {count} {questionLabel}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.description}>
|
||||||
|
Answer yours to see where you match. Their answers stay private until you respond.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Answer ${count} waiting ${questionLabel}`}
|
||||||
|
testID="pending-questions-answer"
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.primary as [string, string, ...string[]]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.button}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>Answer now</Text>
|
||||||
|
<Ionicons name="arrow-forward" size={17} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.waitingRow}>
|
||||||
|
<Ionicons name="time-outline" size={13} color={colors.textTertiary} />
|
||||||
|
<Text style={styles.waitingText}>
|
||||||
|
Oldest waiting · {formatWaitingSince(oldestQuestion.partnerAnsweredAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
padding: spacing.md,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(232, 164, 174, 0.36)',
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
borderBottomLeftRadius: 96,
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
|
},
|
||||||
|
labelRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
labelGroup: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
shadowColor: colors.primary,
|
||||||
|
shadowOpacity: 0.55,
|
||||||
|
shadowRadius: 6,
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.premium.rose,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
countBadge: {
|
||||||
|
minWidth: 32,
|
||||||
|
height: 26,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
countText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
...typography.title3,
|
||||||
|
color: colors.text,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
paddingRight: spacing.xl,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
minHeight: 46,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
...typography.callout,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
waitingRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
waitingText: {
|
||||||
|
...typography.caption2,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,3 +4,4 @@ export { CompactHeader } from './CompactHeader';
|
|||||||
export { CircularProgress } from './CircularProgress';
|
export { CircularProgress } from './CircularProgress';
|
||||||
export { LiveDrawTile } from './LiveDrawTile';
|
export { LiveDrawTile } from './LiveDrawTile';
|
||||||
export { DaresTile } from './DaresTile';
|
export { DaresTile } from './DaresTile';
|
||||||
|
export { PendingQuestionsCard } from './PendingQuestionsCard';
|
||||||
@@ -216,6 +216,10 @@ export const Events = {
|
|||||||
questionSkipped: () => logEvent("question_skipped"),
|
questionSkipped: () => logEvent("question_skipped"),
|
||||||
matchCreated: (matchType: string) => logEvent("match_created", { match_type: matchType }),
|
matchCreated: (matchType: string) => logEvent("match_created", { match_type: matchType }),
|
||||||
allQuestionsExhausted: () => logEvent("all_questions_exhausted"),
|
allQuestionsExhausted: () => logEvent("all_questions_exhausted"),
|
||||||
|
pendingQuestionsPromptViewed: (count: number) =>
|
||||||
|
logEvent("pending_questions_prompt_viewed", { question_count: count }),
|
||||||
|
pendingQuestionsPromptTapped: (count: number) =>
|
||||||
|
logEvent("pending_questions_prompt_tapped", { question_count: count }),
|
||||||
|
|
||||||
// Chat events
|
// Chat events
|
||||||
matchViewed: () => logEvent("match_viewed"),
|
matchViewed: () => logEvent("match_viewed"),
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Notifications.setNotificationHandler({
|
|||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface NotificationData {
|
export interface NotificationData {
|
||||||
type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary";
|
type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary" | "catchup_reminder";
|
||||||
match_id?: string;
|
match_id?: string;
|
||||||
message_id?: string;
|
message_id?: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
@@ -36,6 +36,7 @@ const notificationTypes = new Set<NotificationData["type"]>([
|
|||||||
"partner_activity",
|
"partner_activity",
|
||||||
"nudge",
|
"nudge",
|
||||||
"weekly_summary",
|
"weekly_summary",
|
||||||
|
"catchup_reminder",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isNotificationData(value: unknown): value is NotificationData {
|
function isNotificationData(value: unknown): value is NotificationData {
|
||||||
@@ -254,12 +255,18 @@ export function handleNotificationResponse(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.type === "partner_activity") {
|
if (data.type === "partner_activity") {
|
||||||
// Navigate to Matches > Your Turn tab (questions to answer)
|
// Navigate to Matches > Your Turn tab (questions to answer)
|
||||||
useMatchStore.getState().setCurrentView('pending');
|
useMatchStore.getState().setCurrentView('pending');
|
||||||
router.push("/(app)/matches");
|
router.push("/(app)/matches");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.type === "catchup_reminder") {
|
||||||
|
useMatchStore.getState().setCurrentView('pending');
|
||||||
|
router.push("/(app)/swipe?mode=pending");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!data.match_id) {
|
if (!data.match_id) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in new issue
Block a user