mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(dares): ship the dares loop to mobile
Completes the half-built dares feature: send a dare, respond, report done, confirm, and get a push notification on every transition. Backend (apps/api): - New `dares` domain with couple-shared premium entitlement, a free weekly send allowance, and server-enforced actor/state rules on every transition. - `submitted` status so the recipient reports completion and the sender confirms, rather than the sender guessing. - Notifications ride the existing operations outbox via a trigger on `sent_dares`. Push copy is generic; store compliance forbids dare text in a preview and a test asserts no payload leaks it. - Worker expiry sweep now covers `pending` as well as `active`/`submitted`; an invitation nobody opened previously stayed open forever. Fixes two latent defects: - Deleting any dare that had been sent aborted with a check violation, because `dare_id` is ON DELETE SET NULL while `sent_dares_dare_or_custom_check` required it. Dare text is now snapshotted at send time, which also keeps a couple's history stable across catalogue edits. - The free send allowance was raceable: concurrent requests all read the same count and every one succeeded. The count and insert are now serialized on the sender's profile row. Also ports `content_status` review metadata to the API catalogue, which had no store-safety gating at all, and mirrors the review decision already enforced on the legacy catalogue. Mobile: dares screen (active/send/history), send sheet with duration presets, home tile badging incoming dares, paywall on both proactive and 402 paths. Verified: lint, typecheck, 379 unit tests, build, 71 integration tests against local PostgreSQL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a7470310ae
commit
ce0b188776
34 files changed
+2673
-242
No files matched your search
@@ -1,232 +1,5 @@
|
||||
import { View, Text, StyleSheet, Platform, Pressable, ActivityIndicator } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Animated, { FadeInDown, FadeIn, useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
|
||||
import { GradientBackground } from "../../src/components/ui";
|
||||
import { colors, spacing, radius, typography, animations } from "../../src/theme";
|
||||
import { useFeatureInterest } from "../../src/hooks/useFeatureInterest";
|
||||
|
||||
const ACCENT_COLOR = colors.premium.gold;
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
||||
|
||||
export default function DaresScreen() {
|
||||
const { isInterested, isLoading, isToggling, toggleInterest, isAuthenticated } =
|
||||
useFeatureInterest("dares");
|
||||
|
||||
const scale = useSharedValue(1);
|
||||
const animatedButtonStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
const handlePressIn = () => {
|
||||
scale.value = withSpring(0.96, animations.spring);
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
scale.value = withSpring(1, animations.spring);
|
||||
};
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<View style={styles.container}>
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(600).springify()}
|
||||
style={styles.content}
|
||||
>
|
||||
{/* Title section */}
|
||||
<Text style={styles.label}>ADVENTURE</Text>
|
||||
<Text style={styles.title}>Dares</Text>
|
||||
|
||||
{/* Decorative separator */}
|
||||
<View style={styles.separator}>
|
||||
<View style={styles.separatorLine} />
|
||||
<View style={styles.separatorDiamond} />
|
||||
<View style={styles.separatorLine} />
|
||||
</View>
|
||||
|
||||
{/* Coming soon badge */}
|
||||
<Animated.View
|
||||
entering={FadeIn.delay(300).duration(400)}
|
||||
style={styles.badge}
|
||||
>
|
||||
<Text style={styles.badgeText}>ARRIVING SOON</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Description */}
|
||||
<Text style={styles.description}>
|
||||
Try playful challenges together and create unforgettable moments.
|
||||
</Text>
|
||||
|
||||
{/* Feature hints */}
|
||||
<View style={styles.features}>
|
||||
<View style={styles.featureItem}>
|
||||
<Ionicons name="sparkles" size={16} color={ACCENT_COLOR} />
|
||||
<Text style={styles.featureText}>Provocative challenges</Text>
|
||||
</View>
|
||||
<View style={styles.featureItem}>
|
||||
<Ionicons name="thermometer-outline" size={16} color={ACCENT_COLOR} />
|
||||
<Text style={styles.featureText}>Multiple intensity levels</Text>
|
||||
</View>
|
||||
<View style={styles.featureItem}>
|
||||
<Ionicons name="people-outline" size={16} color={ACCENT_COLOR} />
|
||||
<Text style={styles.featureText}>Designed for two</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bottom teaser */}
|
||||
<Text style={styles.teaser}>Are you ready to play?</Text>
|
||||
|
||||
{/* Interest button - always reserve space to prevent layout shift */}
|
||||
<View style={styles.interestButtonContainer}>
|
||||
{isAuthenticated && !isLoading && (
|
||||
<Animated.View entering={FadeIn.delay(500).duration(400)}>
|
||||
<AnimatedPressable
|
||||
onPress={toggleInterest}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={isToggling}
|
||||
style={[
|
||||
styles.interestButton,
|
||||
isInterested && styles.interestButtonActive,
|
||||
animatedButtonStyle,
|
||||
]}
|
||||
>
|
||||
{isToggling ? (
|
||||
<ActivityIndicator size="small" color={ACCENT_COLOR} />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons
|
||||
name={isInterested ? "checkmark-circle" : "notifications-outline"}
|
||||
size={16}
|
||||
color={ACCENT_COLOR}
|
||||
/>
|
||||
<Text style={styles.interestButtonText}>
|
||||
{isInterested ? "You're on the list!" : "I'm interested"}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</AnimatedPressable>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: 60,
|
||||
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
|
||||
},
|
||||
content: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
label: {
|
||||
...typography.caption1,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 3,
|
||||
color: ACCENT_COLOR,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
title: {
|
||||
...typography.largeTitle,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
},
|
||||
separator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginVertical: spacing.lg,
|
||||
width: 140,
|
||||
},
|
||||
separatorLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.3)',
|
||||
},
|
||||
separatorDiamond: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
backgroundColor: ACCENT_COLOR,
|
||||
transform: [{ rotate: '45deg' }],
|
||||
marginHorizontal: spacing.md,
|
||||
opacity: 0.6,
|
||||
},
|
||||
badge: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
interestButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
interestButtonActive: {
|
||||
backgroundColor: colors.background,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
interestButtonText: {
|
||||
...typography.subhead,
|
||||
fontWeight: '600',
|
||||
color: ACCENT_COLOR,
|
||||
},
|
||||
badgeText: {
|
||||
...typography.caption2,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 2,
|
||||
color: ACCENT_COLOR,
|
||||
},
|
||||
description: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
features: {
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
featureItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
featureText: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
teaser: {
|
||||
...typography.footnote,
|
||||
fontStyle: 'italic',
|
||||
color: colors.textTertiary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
interestButtonContainer: {
|
||||
marginTop: spacing.xl,
|
||||
minHeight: 44,
|
||||
},
|
||||
});
|
||||
import { DaresScreen } from "../../src/features/dares";
|
||||
|
||||
export default function DaresRoute() {
|
||||
return <DaresScreen />;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { BlurView } from 'expo-blur';
|
||||
import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store';
|
||||
import { GradientBackground } from '../../src/components/ui';
|
||||
import { Paywall } from '../../src/components/paywall';
|
||||
import { CompactHeader, ContentRow, LiveDrawTile } from '../../src/components/discovery';
|
||||
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery';
|
||||
import { colors, spacing, typography, radius } from '../../src/theme';
|
||||
import type { QuestionPack, Category } from '../../src/types';
|
||||
|
||||
@@ -117,6 +117,9 @@ export default function DiscoveryScreen() {
|
||||
{/* Live Draw Activity Tile */}
|
||||
<LiveDrawTile delay={100} />
|
||||
|
||||
{/* Dares */}
|
||||
<DaresTile delay={150} />
|
||||
|
||||
{/* Content Rows by Category */}
|
||||
{packsByCategory.map((group, index) => (
|
||||
<ContentRow
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react-native";
|
||||
import { useDares } from "@/features/dares/hooks/useDares";
|
||||
import { ApiError, apiClient } from "@/lib/apiClient";
|
||||
import type { SentDare } from "@/features/dares/types";
|
||||
|
||||
const incoming: SentDare = {
|
||||
id: "dare-1", couple_id: "couple-1", dare_id: "catalogue-1", text: "A dare", intensity: 2,
|
||||
is_custom: false, sender_id: "partner", recipient_id: "me", direction: "incoming",
|
||||
status: "pending", sender_notes: null, sent_at: "2026-08-28T10:00:00.000Z",
|
||||
accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
|
||||
};
|
||||
|
||||
const catalog = {
|
||||
entitlement: { is_premium: false, can_send_custom: false, weekly_send_limit: 3, sends_remaining: 2 },
|
||||
packs: [],
|
||||
};
|
||||
|
||||
const stats = { sent: 1, received: 1, completed_together: 0, active: 1, completed_by_me: 0, completed_by_partner: 0 };
|
||||
|
||||
function mockLoad() {
|
||||
return jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
|
||||
if (path.startsWith("/v1/dares?filter=active")) return { dares: [incoming] } as never;
|
||||
if (path.startsWith("/v1/dares?filter=history")) return { dares: [] } as never;
|
||||
if (path === "/v1/dares/packs") return catalog as never;
|
||||
if (path === "/v1/dares/stats") return stats as never;
|
||||
throw new Error(`unexpected path ${path}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("useDares", () => {
|
||||
beforeEach(() => jest.restoreAllMocks());
|
||||
|
||||
it("loads the inbox, catalogue and stats together", async () => {
|
||||
mockLoad();
|
||||
const { result } = renderHook(() => useDares());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.active).toHaveLength(1);
|
||||
expect(result.current.history).toHaveLength(0);
|
||||
expect(result.current.catalog?.entitlement.sends_remaining).toBe(2);
|
||||
expect(result.current.stats?.active).toBe(1);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("posts a response and refreshes", async () => {
|
||||
mockLoad();
|
||||
const postSpy = jest.spyOn(apiClient, "post").mockResolvedValue({ dare: { ...incoming, status: "active" } } as never);
|
||||
const { result } = renderHook(() => useDares());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.respond("dare-1", "accept");
|
||||
});
|
||||
expect(postSpy).toHaveBeenCalledWith("/v1/dares/dare-1/respond", { action: "accept" });
|
||||
});
|
||||
|
||||
it("routes a premium refusal to the paywall rather than the error banner", async () => {
|
||||
mockLoad();
|
||||
jest.spyOn(apiClient, "post").mockRejectedValue(new ApiError("Custom dares require premium", 402));
|
||||
const { result } = renderHook(() => useDares());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.send({ custom_dare_text: "mine" });
|
||||
});
|
||||
expect(result.current.paywallReason).toBe("Custom dares require premium");
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
act(() => result.current.clearPaywall());
|
||||
expect(result.current.paywallReason).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces non-premium failures inline", async () => {
|
||||
mockLoad();
|
||||
jest.spyOn(apiClient, "post").mockRejectedValue(new ApiError("A pending dare cannot change to submitted", 409));
|
||||
const { result } = renderHook(() => useDares());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submit("dare-1");
|
||||
});
|
||||
expect(result.current.error).toBe("A pending dare cannot change to submitted");
|
||||
expect(result.current.paywallReason).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a load failure without crashing", async () => {
|
||||
jest.spyOn(apiClient, "get").mockRejectedValue(new ApiError("offline", 500));
|
||||
const { result } = renderHook(() => useDares());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBe("offline");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { router } from 'expo-router';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
import { colors, spacing, radius, typography, shadows, tileColors } from '../../theme';
|
||||
import { useAuthStore } from '../../store';
|
||||
import { useIncomingDareCount } from '../../features/dares/hooks/useIncomingDareCount';
|
||||
|
||||
const HORIZONTAL_PADDING = spacing.lg;
|
||||
const TILE_HEIGHT = 100;
|
||||
|
||||
interface DaresTileProps {
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function DaresTile({ delay = 0 }: DaresTileProps) {
|
||||
const { user } = useAuthStore();
|
||||
const paired = Boolean(user?.couple_id);
|
||||
const pending = useIncomingDareCount(paired);
|
||||
|
||||
if (!paired) return null;
|
||||
|
||||
const handlePress = () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.push('/(app)/dares' as never);
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(delay).duration(500).springify()}
|
||||
style={styles.container}
|
||||
>
|
||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.85} style={styles.tileWrapper}>
|
||||
<View style={[styles.tile, shadows.md]}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="flame" size={24} color="rgba(255, 255, 255, 0.9)" />
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>Dares</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{pending > 0
|
||||
? `${pending} dare${pending === 1 ? '' : 's'} waiting for you`
|
||||
: 'Challenge your partner to something playful'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{pending > 0 ? (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{pending}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Ionicons name="chevron-forward" size={20} color="rgba(255, 255, 255, 0.6)" />
|
||||
)}
|
||||
|
||||
<View style={styles.overlay} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
tileWrapper: {
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
},
|
||||
tile: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: tileColors.amber,
|
||||
borderRadius: radius.lg,
|
||||
padding: spacing.md,
|
||||
height: TILE_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
fontWeight: '700',
|
||||
},
|
||||
subtitle: {
|
||||
...typography.caption1,
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
marginTop: 2,
|
||||
},
|
||||
badge: {
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
paddingHorizontal: 6,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.background,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
badgeText: {
|
||||
...typography.caption1,
|
||||
color: colors.text,
|
||||
fontWeight: '700',
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: radius.lg,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
export { ContentTile, TILE_WIDTH, TILE_HEIGHT } from './ContentTile';
|
||||
export { ContentRow } from './ContentRow';
|
||||
export { CompactHeader } from './CompactHeader';
|
||||
export { CircularProgress } from './CircularProgress';
|
||||
export { LiveDrawTile } from './LiveDrawTile';
|
||||
export { ContentTile, TILE_WIDTH, TILE_HEIGHT } from './ContentTile';
|
||||
export { ContentRow } from './ContentRow';
|
||||
export { CompactHeader } from './CompactHeader';
|
||||
export { CircularProgress } from './CircularProgress';
|
||||
export { LiveDrawTile } from './LiveDrawTile';
|
||||
export { DaresTile } from './DaresTile';
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Animated, { FadeIn } from "react-native-reanimated";
|
||||
import { GradientBackground } from "../../components/ui";
|
||||
import { Paywall } from "../../components/paywall";
|
||||
import { colors, featureColors, radius, spacing, typography } from "../../theme";
|
||||
import { DareCard, DarePackGrid, SendDareSheet } from "./components";
|
||||
import { useDares } from "./hooks/useDares";
|
||||
import type { DarePack } from "./types";
|
||||
|
||||
const ACCENT = featureColors.dares.accent;
|
||||
|
||||
type Tab = "active" | "send" | "history";
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "active", label: "Active" },
|
||||
{ key: "send", label: "Send" },
|
||||
{ key: "history", label: "History" },
|
||||
];
|
||||
|
||||
export function DaresScreen() {
|
||||
const dares = useDares();
|
||||
const [tab, setTab] = useState<Tab>("active");
|
||||
const [sheetPack, setSheetPack] = useState<DarePack | null>(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [paywallOpen, setPaywallOpen] = useState(false);
|
||||
|
||||
const openPaywall = useCallback(() => {
|
||||
setSheetOpen(false);
|
||||
setPaywallOpen(true);
|
||||
}, []);
|
||||
|
||||
const closePaywall = useCallback(() => {
|
||||
setPaywallOpen(false);
|
||||
dares.clearPaywall();
|
||||
}, [dares]);
|
||||
|
||||
const entitlement = dares.catalog?.entitlement ?? null;
|
||||
const premium = entitlement?.is_premium ?? false;
|
||||
|
||||
const handleSelectPack = useCallback(
|
||||
(pack: DarePack) => {
|
||||
if (pack.is_premium && !premium) {
|
||||
openPaywall();
|
||||
return;
|
||||
}
|
||||
setSheetPack(pack);
|
||||
setSheetOpen(true);
|
||||
},
|
||||
[premium, openPaywall],
|
||||
);
|
||||
|
||||
const handleWriteCustom = useCallback(() => {
|
||||
if (!premium) {
|
||||
openPaywall();
|
||||
return;
|
||||
}
|
||||
setSheetPack(null);
|
||||
setSheetOpen(true);
|
||||
}, [premium, openPaywall]);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(dareId: string, action: "accept" | "decline" | "submit" | "complete" | "cancel") => {
|
||||
if (action === "accept" || action === "decline") {
|
||||
void dares.respond(dareId, action);
|
||||
return;
|
||||
}
|
||||
void dares[action](dareId);
|
||||
},
|
||||
[dares],
|
||||
);
|
||||
|
||||
// A premium refusal from the API opens the paywall even when the client thought
|
||||
// the action was allowed (stale entitlement, or a plan that lapsed mid-session).
|
||||
const { paywallReason } = dares;
|
||||
useEffect(() => {
|
||||
if (paywallReason) setPaywallOpen(true);
|
||||
}, [paywallReason]);
|
||||
|
||||
const list = tab === "history" ? dares.history : dares.active;
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.container}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={false} onRefresh={() => void dares.refresh()} tintColor={ACCENT} />
|
||||
}
|
||||
>
|
||||
<Text style={styles.label}>ADVENTURE</Text>
|
||||
<Text style={styles.title}>Dares</Text>
|
||||
|
||||
{dares.stats ? (
|
||||
<View style={styles.statsRow}>
|
||||
<Stat value={dares.stats.completed_together} label="Completed" />
|
||||
<Stat value={dares.stats.active} label="In play" />
|
||||
<Stat value={dares.stats.sent} label="You sent" />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!premium && entitlement?.sends_remaining !== null && entitlement !== null ? (
|
||||
<Pressable style={styles.quotaBanner} onPress={openPaywall}>
|
||||
<Ionicons name="flash-outline" size={14} color={ACCENT} />
|
||||
<Text style={styles.quotaText}>
|
||||
{entitlement.sends_remaining} of {entitlement.weekly_send_limit} free dares left this week
|
||||
</Text>
|
||||
<Text style={styles.quotaCta}>Upgrade</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<View style={styles.tabs}>
|
||||
{TABS.map(({ key, label }) => (
|
||||
<Pressable
|
||||
key={key}
|
||||
onPress={() => setTab(key)}
|
||||
style={[styles.tab, tab === key && styles.tabActive]}
|
||||
>
|
||||
<Text style={[styles.tabText, tab === key && styles.tabTextActive]}>{label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{dares.error ? <Text style={styles.error}>{dares.error}</Text> : null}
|
||||
|
||||
{dares.loading ? (
|
||||
<ActivityIndicator color={ACCENT} style={styles.loader} />
|
||||
) : tab === "send" ? (
|
||||
<DarePackGrid
|
||||
packs={dares.catalog?.packs ?? []}
|
||||
entitlement={entitlement}
|
||||
onSelectPack={handleSelectPack}
|
||||
onWriteCustom={handleWriteCustom}
|
||||
/>
|
||||
) : list.length === 0 ? (
|
||||
<Animated.View entering={FadeIn.duration(300)} style={styles.empty}>
|
||||
<Ionicons name="sparkles-outline" size={28} color={ACCENT} />
|
||||
<Text style={styles.emptyTitle}>
|
||||
{tab === "active" ? "No dares in play" : "Nothing here yet"}
|
||||
</Text>
|
||||
<Text style={styles.emptyBody}>
|
||||
{tab === "active"
|
||||
? "Send your partner a dare and see what happens."
|
||||
: "Completed and declined dares will show up here."}
|
||||
</Text>
|
||||
{tab === "active" ? (
|
||||
<Pressable style={styles.emptyButton} onPress={() => setTab("send")}>
|
||||
<Text style={styles.emptyButtonText}>Browse dares</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
) : (
|
||||
list.map((dare) => (
|
||||
<DareCard
|
||||
key={dare.id}
|
||||
dare={dare}
|
||||
busy={dares.busyDareId === dare.id}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<SendDareSheet
|
||||
visible={sheetOpen}
|
||||
pack={sheetPack}
|
||||
canSendCustom={entitlement?.can_send_custom ?? false}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onSend={dares.send}
|
||||
onRequestPremium={openPaywall}
|
||||
/>
|
||||
|
||||
<Paywall
|
||||
visible={paywallOpen}
|
||||
onClose={closePaywall}
|
||||
onSuccess={() => {
|
||||
closePaywall();
|
||||
void dares.refresh();
|
||||
}}
|
||||
/>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ value, label }: { value: number; label: string }) {
|
||||
return (
|
||||
<View style={styles.stat}>
|
||||
<Text style={styles.statValue}>{value}</Text>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { flex: 1 },
|
||||
container: { paddingHorizontal: spacing.lg, paddingTop: 60, paddingBottom: 120 },
|
||||
label: {
|
||||
...typography.caption1, fontWeight: "600", letterSpacing: 3,
|
||||
color: ACCENT, marginBottom: spacing.xs,
|
||||
},
|
||||
title: { ...typography.largeTitle, color: colors.text, marginBottom: spacing.lg },
|
||||
statsRow: { flexDirection: "row", gap: spacing.sm, marginBottom: spacing.md },
|
||||
stat: {
|
||||
flex: 1, alignItems: "center",
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.md, borderWidth: 1, borderColor: colors.border,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
statValue: { ...typography.title2, color: ACCENT },
|
||||
statLabel: { ...typography.caption2, color: colors.textSecondary, marginTop: 2 },
|
||||
quotaBanner: {
|
||||
flexDirection: "row", alignItems: "center", gap: spacing.sm,
|
||||
backgroundColor: "rgba(212,175,55,0.08)",
|
||||
borderRadius: radius.md, borderWidth: 1, borderColor: "rgba(212,175,55,0.3)",
|
||||
paddingHorizontal: spacing.md, paddingVertical: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
quotaText: { ...typography.caption1, color: colors.textSecondary, flex: 1 },
|
||||
quotaCta: { ...typography.caption1, color: ACCENT, fontWeight: "700" },
|
||||
tabs: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.full,
|
||||
padding: 3,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
tab: { flex: 1, alignItems: "center", paddingVertical: spacing.sm, borderRadius: radius.full },
|
||||
tabActive: { backgroundColor: ACCENT },
|
||||
tabText: { ...typography.subhead, color: colors.textSecondary, fontWeight: "600" },
|
||||
tabTextActive: { color: colors.background },
|
||||
loader: { marginTop: spacing.xl },
|
||||
error: { ...typography.footnote, color: colors.error, marginBottom: spacing.md },
|
||||
empty: { alignItems: "center", paddingVertical: spacing.xxl, gap: spacing.sm },
|
||||
emptyTitle: { ...typography.headline, color: colors.text, marginTop: spacing.sm },
|
||||
emptyBody: {
|
||||
...typography.subhead, color: colors.textSecondary,
|
||||
textAlign: "center", paddingHorizontal: spacing.lg,
|
||||
},
|
||||
emptyButton: {
|
||||
marginTop: spacing.md,
|
||||
paddingHorizontal: spacing.lg, paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radius.full, borderWidth: 1, borderColor: ACCENT,
|
||||
},
|
||||
emptyButtonText: { ...typography.subhead, color: ACCENT, fontWeight: "600" },
|
||||
});
|
||||
|
||||
export default DaresScreen;
|
||||
@@ -0,0 +1,185 @@
|
||||
import { memo } from "react";
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
|
||||
import type { SentDare } from "../types";
|
||||
|
||||
const ACCENT = featureColors.dares.accent;
|
||||
|
||||
/** The one action each side owns at each step of the loop. */
|
||||
type Action = "accept" | "decline" | "submit" | "complete" | "cancel";
|
||||
|
||||
export interface DareCardProps {
|
||||
dare: SentDare;
|
||||
busy: boolean;
|
||||
onAction: (dareId: string, action: Action) => void;
|
||||
}
|
||||
|
||||
function relativeDeadline(expiresAt: string | null): string | null {
|
||||
if (!expiresAt) return null;
|
||||
const remainingMs = new Date(expiresAt).getTime() - Date.now();
|
||||
if (remainingMs <= 0) return "Time's up";
|
||||
const hours = Math.floor(remainingMs / 3_600_000);
|
||||
if (hours >= 48) return `${Math.floor(hours / 24)} days left`;
|
||||
if (hours >= 1) return `${hours}h left`;
|
||||
return `${Math.max(1, Math.round(remainingMs / 60_000))}m left`;
|
||||
}
|
||||
|
||||
function statusLabel(dare: SentDare): string {
|
||||
const them = dare.direction === "incoming" ? "They dared you" : "You dared them";
|
||||
switch (dare.status) {
|
||||
case "pending": return dare.direction === "incoming" ? "Waiting on you" : "Waiting on them";
|
||||
case "active": return dare.direction === "incoming" ? "Your turn" : "They accepted";
|
||||
case "submitted": return dare.direction === "incoming" ? "Waiting to be confirmed" : "They say it's done";
|
||||
case "completed": return "Completed";
|
||||
case "declined": return "Declined";
|
||||
case "cancelled": return "Cancelled";
|
||||
case "expired": return "Expired";
|
||||
default: return them;
|
||||
}
|
||||
}
|
||||
|
||||
/** Only surfaces buttons the viewer is actually allowed to press. */
|
||||
function actionsFor(dare: SentDare): { action: Action; label: string; primary?: boolean }[] {
|
||||
if (dare.direction === "incoming") {
|
||||
if (dare.status === "pending") {
|
||||
return [
|
||||
{ action: "accept", label: "Accept", primary: true },
|
||||
{ action: "decline", label: "Pass" },
|
||||
];
|
||||
}
|
||||
if (dare.status === "active") return [{ action: "submit", label: "I did it", primary: true }];
|
||||
return [];
|
||||
}
|
||||
if (dare.status === "submitted") {
|
||||
return [
|
||||
{ action: "complete", label: "Confirm", primary: true },
|
||||
{ action: "cancel", label: "Cancel" },
|
||||
];
|
||||
}
|
||||
if (dare.status === "pending" || dare.status === "active") {
|
||||
return [{ action: "cancel", label: "Cancel" }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export const DareCard = memo(function DareCard({ dare, busy, onAction }: DareCardProps) {
|
||||
const deadline = relativeDeadline(dare.expires_at);
|
||||
const actions = actionsFor(dare);
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.directionRow}>
|
||||
<Ionicons
|
||||
name={dare.direction === "incoming" ? "arrow-down-circle" : "arrow-up-circle"}
|
||||
size={16}
|
||||
color={ACCENT}
|
||||
/>
|
||||
<Text style={styles.status}>{statusLabel(dare)}</Text>
|
||||
</View>
|
||||
<View style={styles.intensityRow}>
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[styles.pip, index < dare.intensity && styles.pipFilled]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.text}>{dare.text}</Text>
|
||||
|
||||
{dare.sender_notes ? <Text style={styles.notes}>“{dare.sender_notes}”</Text> : null}
|
||||
|
||||
{deadline ? (
|
||||
<View style={styles.deadlineRow}>
|
||||
<Ionicons name="time-outline" size={13} color={colors.textTertiary} />
|
||||
<Text style={styles.deadline}>{deadline}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{actions.length > 0 ? (
|
||||
<View style={styles.actions}>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={ACCENT} />
|
||||
) : (
|
||||
actions.map(({ action, label, primary }) => (
|
||||
<Pressable
|
||||
key={action}
|
||||
onPress={() => onAction(dare.id, action)}
|
||||
style={[styles.button, primary ? styles.buttonPrimary : styles.buttonSecondary]}
|
||||
>
|
||||
<Text style={[styles.buttonText, primary && styles.buttonTextPrimary]}>{label}</Text>
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
directionRow: { flexDirection: "row", alignItems: "center", gap: spacing.xs },
|
||||
status: {
|
||||
...typography.caption1,
|
||||
color: ACCENT,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
intensityRow: { flexDirection: "row", gap: 3 },
|
||||
pip: {
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
pipFilled: { backgroundColor: ACCENT },
|
||||
text: { ...typography.body, color: colors.text },
|
||||
notes: {
|
||||
...typography.footnote,
|
||||
color: colors.textSecondary,
|
||||
fontStyle: "italic",
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
deadlineRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
deadline: { ...typography.caption1, color: colors.textTertiary },
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.md,
|
||||
minHeight: 36,
|
||||
},
|
||||
button: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
buttonPrimary: { backgroundColor: ACCENT, borderColor: ACCENT },
|
||||
buttonSecondary: { backgroundColor: "transparent", borderColor: colors.border },
|
||||
buttonText: { ...typography.subhead, fontWeight: "600", color: colors.textSecondary },
|
||||
buttonTextPrimary: { color: colors.background },
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
|
||||
import type { DareEntitlement, DarePack } from "../types";
|
||||
|
||||
const ACCENT = featureColors.dares.accent;
|
||||
|
||||
export interface DarePackGridProps {
|
||||
packs: DarePack[];
|
||||
entitlement: DareEntitlement | null;
|
||||
onSelectPack: (pack: DarePack) => void;
|
||||
onWriteCustom: () => void;
|
||||
}
|
||||
|
||||
export function DarePackGrid({ packs, entitlement, onSelectPack, onWriteCustom }: DarePackGridProps) {
|
||||
const premium = entitlement?.is_premium ?? false;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Pressable style={styles.customRow} onPress={onWriteCustom}>
|
||||
<View style={styles.customIcon}>
|
||||
<Ionicons name={premium ? "create-outline" : "lock-closed"} size={18} color={ACCENT} />
|
||||
</View>
|
||||
<View style={styles.customCopy}>
|
||||
<Text style={styles.customTitle}>Write your own</Text>
|
||||
<Text style={styles.customSubtitle}>
|
||||
{premium ? "Make it personal" : "Premium"}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||
</Pressable>
|
||||
|
||||
{packs.map((pack) => {
|
||||
const locked = pack.is_premium && !premium;
|
||||
return (
|
||||
<Pressable key={pack.id} style={styles.packRow} onPress={() => onSelectPack(pack)}>
|
||||
<Text style={styles.packIcon}>{pack.icon ?? "🔥"}</Text>
|
||||
<View style={styles.packCopy}>
|
||||
<View style={styles.packTitleRow}>
|
||||
<Text style={styles.packTitle}>{pack.name}</Text>
|
||||
{locked ? <Ionicons name="lock-closed" size={13} color={ACCENT} /> : null}
|
||||
</View>
|
||||
<Text style={styles.packSubtitle} numberOfLines={1}>
|
||||
{pack.description ?? `${pack.dare_count} dares`}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const row = {
|
||||
flexDirection: "row" as const,
|
||||
alignItems: "center" as const,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
gap: spacing.md,
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
customRow: { ...row, borderColor: "rgba(212,175,55,0.35)" },
|
||||
customIcon: {
|
||||
width: 36, height: 36, borderRadius: radius.full,
|
||||
alignItems: "center", justifyContent: "center",
|
||||
backgroundColor: "rgba(212,175,55,0.12)",
|
||||
},
|
||||
customCopy: { flex: 1 },
|
||||
customTitle: { ...typography.headline, color: colors.text },
|
||||
customSubtitle: { ...typography.caption1, color: ACCENT, marginTop: 2 },
|
||||
packRow: row,
|
||||
packIcon: { fontSize: 26, width: 36, textAlign: "center" },
|
||||
packCopy: { flex: 1 },
|
||||
packTitleRow: { flexDirection: "row", alignItems: "center", gap: spacing.xs },
|
||||
packTitle: { ...typography.headline, color: colors.text },
|
||||
packSubtitle: { ...typography.caption1, color: colors.textSecondary, marginTop: 2 },
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator, KeyboardAvoidingView, Modal, Platform, Pressable,
|
||||
ScrollView, StyleSheet, Text, TextInput, View,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { daresApi } from "../../../lib/daresApi";
|
||||
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
|
||||
import { DURATION_OPTIONS, type DareItem, type DarePack, type SendDarePayload } from "../types";
|
||||
|
||||
const ACCENT = featureColors.dares.accent;
|
||||
|
||||
export interface SendDareSheetProps {
|
||||
visible: boolean;
|
||||
pack: DarePack | null;
|
||||
/** Null while the catalogue is loading; drives the custom-dare affordance. */
|
||||
canSendCustom: boolean;
|
||||
onClose: () => void;
|
||||
onSend: (payload: SendDarePayload) => Promise<boolean>;
|
||||
onRequestPremium: () => void;
|
||||
}
|
||||
|
||||
export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, onRequestPremium }: SendDareSheetProps) {
|
||||
const [dares, setDares] = useState<DareItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [customText, setCustomText] = useState("");
|
||||
const [durationHours, setDurationHours] = useState<number | null>(24);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !pack) return;
|
||||
setLoading(true);
|
||||
setSelectedId(null);
|
||||
setCustomText("");
|
||||
setNotes("");
|
||||
daresApi
|
||||
.packDares(pack.id)
|
||||
.then((result) => setDares(result.dares))
|
||||
.catch(() => setDares([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [visible, pack]);
|
||||
|
||||
const isCustom = pack === null;
|
||||
const canSubmit = isCustom ? customText.trim().length > 0 : selectedId !== null;
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!canSubmit || sending) return;
|
||||
setSending(true);
|
||||
const payload: SendDarePayload = isCustom
|
||||
? { custom_dare_text: customText.trim(), custom_dare_intensity: 3, duration_hours: durationHours }
|
||||
: { dare_id: selectedId!, duration_hours: durationHours };
|
||||
if (notes.trim()) payload.sender_notes = notes.trim();
|
||||
const sent = await onSend(payload);
|
||||
setSending(false);
|
||||
if (sent) onClose();
|
||||
}, [canSubmit, sending, isCustom, customText, selectedId, durationHours, notes, onSend, onClose]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
|
||||
<View style={styles.backdrop}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={styles.sheet}
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{isCustom ? "Write a dare" : pack?.name}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={12}>
|
||||
<Ionicons name="close" size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.body} keyboardShouldPersistTaps="handled">
|
||||
{isCustom ? (
|
||||
canSendCustom ? (
|
||||
<TextInput
|
||||
style={styles.customInput}
|
||||
value={customText}
|
||||
onChangeText={setCustomText}
|
||||
placeholder="Dare your partner to..."
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
multiline
|
||||
maxLength={500}
|
||||
/>
|
||||
) : (
|
||||
<Pressable style={styles.lockedCard} onPress={onRequestPremium}>
|
||||
<Ionicons name="lock-closed" size={18} color={ACCENT} />
|
||||
<Text style={styles.lockedText}>Custom dares are a premium feature</Text>
|
||||
</Pressable>
|
||||
)
|
||||
) : loading ? (
|
||||
<ActivityIndicator color={ACCENT} style={styles.loader} />
|
||||
) : (
|
||||
dares.map((dare) => {
|
||||
const selected = dare.id === selectedId;
|
||||
return (
|
||||
<Pressable
|
||||
key={dare.id}
|
||||
onPress={() => setSelectedId(dare.id)}
|
||||
style={[styles.option, selected && styles.optionSelected]}
|
||||
>
|
||||
<Text style={[styles.optionText, selected && styles.optionTextSelected]}>
|
||||
{dare.text}
|
||||
</Text>
|
||||
<View style={styles.intensityRow}>
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[styles.pip, index < dare.intensity && styles.pipFilled]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
<Text style={styles.sectionLabel}>TIME LIMIT</Text>
|
||||
<View style={styles.durationRow}>
|
||||
{DURATION_OPTIONS.map((option) => {
|
||||
const selected = option.hours === durationHours;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.label}
|
||||
onPress={() => setDurationHours(option.hours)}
|
||||
style={[styles.chip, selected && styles.chipSelected]}
|
||||
>
|
||||
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionLabel}>ADD A NOTE</Text>
|
||||
<TextInput
|
||||
style={styles.notesInput}
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
placeholder="Optional"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
maxLength={500}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
<Pressable
|
||||
onPress={handleSend}
|
||||
disabled={!canSubmit || sending}
|
||||
style={[styles.sendButton, (!canSubmit || sending) && styles.sendButtonDisabled]}
|
||||
>
|
||||
{sending ? (
|
||||
<ActivityIndicator size="small" color={colors.background} />
|
||||
) : (
|
||||
<Text style={styles.sendButtonText}>Send dare</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.6)", justifyContent: "flex-end" },
|
||||
sheet: {
|
||||
backgroundColor: colors.background,
|
||||
borderTopLeftRadius: radius.xxl,
|
||||
borderTopRightRadius: radius.xxl,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: Platform.OS === "ios" ? spacing.xl : spacing.lg,
|
||||
maxHeight: "88%",
|
||||
},
|
||||
handle: {
|
||||
width: 36, height: 4, borderRadius: radius.full,
|
||||
backgroundColor: colors.border, alignSelf: "center", marginTop: spacing.sm,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row", alignItems: "center", justifyContent: "space-between",
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
title: { ...typography.title3, color: colors.text },
|
||||
body: { marginBottom: spacing.md },
|
||||
loader: { marginVertical: spacing.xl },
|
||||
option: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
optionSelected: { borderColor: ACCENT, backgroundColor: "rgba(212,175,55,0.08)" },
|
||||
optionText: { ...typography.subhead, color: colors.textSecondary },
|
||||
optionTextSelected: { color: colors.text },
|
||||
intensityRow: { flexDirection: "row", gap: 3, marginTop: spacing.sm },
|
||||
pip: { width: 5, height: 5, borderRadius: radius.full, backgroundColor: colors.border },
|
||||
pipFilled: { backgroundColor: ACCENT },
|
||||
customInput: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
minHeight: 100,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
lockedCard: {
|
||||
flexDirection: "row", alignItems: "center", gap: spacing.sm,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.md, borderWidth: 1, borderColor: ACCENT,
|
||||
padding: spacing.md,
|
||||
},
|
||||
lockedText: { ...typography.subhead, color: ACCENT, fontWeight: "600" },
|
||||
sectionLabel: {
|
||||
...typography.caption2,
|
||||
color: colors.textTertiary,
|
||||
letterSpacing: 2,
|
||||
fontWeight: "600",
|
||||
marginTop: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
durationRow: { flexDirection: "row", flexWrap: "wrap", gap: spacing.sm },
|
||||
chip: {
|
||||
paddingHorizontal: spacing.md, paddingVertical: spacing.sm - 2,
|
||||
borderRadius: radius.full, borderWidth: 1, borderColor: colors.border,
|
||||
},
|
||||
chipSelected: { borderColor: ACCENT, backgroundColor: "rgba(212,175,55,0.12)" },
|
||||
chipText: { ...typography.footnote, color: colors.textSecondary },
|
||||
chipTextSelected: { color: ACCENT, fontWeight: "600" },
|
||||
notesInput: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
},
|
||||
sendButton: {
|
||||
backgroundColor: ACCENT,
|
||||
borderRadius: radius.full,
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 50,
|
||||
},
|
||||
sendButtonDisabled: { opacity: 0.4 },
|
||||
sendButtonText: { ...typography.headline, color: colors.background, fontWeight: "700" },
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export { DareCard } from "./DareCard";
|
||||
export type { DareCardProps } from "./DareCard";
|
||||
export { DarePackGrid } from "./DarePackGrid";
|
||||
export type { DarePackGridProps } from "./DarePackGrid";
|
||||
export { SendDareSheet } from "./SendDareSheet";
|
||||
export type { SendDareSheetProps } from "./SendDareSheet";
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { daresApi } from "../../../lib/daresApi";
|
||||
import { ApiError } from "../../../lib/apiClient";
|
||||
import type { DareCatalog, DareStats, SendDarePayload, SentDare } from "../types";
|
||||
|
||||
/** Matches the chat poll cadence; the API has no realtime channel. */
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
export interface UseDaresReturn {
|
||||
active: SentDare[];
|
||||
history: SentDare[];
|
||||
catalog: DareCatalog | null;
|
||||
stats: DareStats | null;
|
||||
loading: boolean;
|
||||
busyDareId: string | null;
|
||||
error: string | null;
|
||||
/** Set when an action was refused for want of premium, so the screen can open the paywall. */
|
||||
paywallReason: string | null;
|
||||
clearPaywall: () => void;
|
||||
refresh: () => Promise<void>;
|
||||
send: (payload: SendDarePayload) => Promise<boolean>;
|
||||
respond: (dareId: string, action: "accept" | "decline") => Promise<void>;
|
||||
submit: (dareId: string) => Promise<void>;
|
||||
complete: (dareId: string) => Promise<void>;
|
||||
cancel: (dareId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useDares(): UseDaresReturn {
|
||||
const [active, setActive] = useState<SentDare[]>([]);
|
||||
const [history, setHistory] = useState<SentDare[]>([]);
|
||||
const [catalog, setCatalog] = useState<DareCatalog | null>(null);
|
||||
const [stats, setStats] = useState<DareStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyDareId, setBusyDareId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [paywallReason, setPaywallReason] = useState<string | null>(null);
|
||||
const fetchingRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (fetchingRef.current) return;
|
||||
fetchingRef.current = true;
|
||||
try {
|
||||
const [activeResult, historyResult, catalogResult, statsResult] = await Promise.all([
|
||||
daresApi.list("active"),
|
||||
daresApi.list("history"),
|
||||
daresApi.catalog(),
|
||||
daresApi.stats(),
|
||||
]);
|
||||
if (!mountedRef.current) return;
|
||||
setActive(activeResult.dares);
|
||||
setHistory(historyResult.dares);
|
||||
setCatalog(catalogResult);
|
||||
setStats(statsResult);
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
if (mountedRef.current) {
|
||||
setError(cause instanceof ApiError ? cause.message : "Could not load dares");
|
||||
}
|
||||
} finally {
|
||||
fetchingRef.current = false;
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void refresh();
|
||||
const timer = setInterval(() => void refresh(), POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
/** Premium refusals steer to the paywall; everything else surfaces inline. */
|
||||
const handleFailure = useCallback((cause: unknown) => {
|
||||
if (cause instanceof ApiError && (cause.status === 402 || cause.status === 403)) {
|
||||
setPaywallReason(cause.message);
|
||||
return;
|
||||
}
|
||||
setError(cause instanceof ApiError ? cause.message : "Something went wrong");
|
||||
}, []);
|
||||
|
||||
const act = useCallback(
|
||||
async (dareId: string, operation: () => Promise<unknown>) => {
|
||||
setBusyDareId(dareId);
|
||||
try {
|
||||
await operation();
|
||||
await refresh();
|
||||
} catch (cause) {
|
||||
handleFailure(cause);
|
||||
} finally {
|
||||
if (mountedRef.current) setBusyDareId(null);
|
||||
}
|
||||
},
|
||||
[refresh, handleFailure],
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
async (payload: SendDarePayload) => {
|
||||
try {
|
||||
await daresApi.send(payload);
|
||||
await refresh();
|
||||
return true;
|
||||
} catch (cause) {
|
||||
handleFailure(cause);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[refresh, handleFailure],
|
||||
);
|
||||
|
||||
return {
|
||||
active,
|
||||
history,
|
||||
catalog,
|
||||
stats,
|
||||
loading,
|
||||
busyDareId,
|
||||
error,
|
||||
paywallReason,
|
||||
clearPaywall: useCallback(() => setPaywallReason(null), []),
|
||||
refresh,
|
||||
send,
|
||||
respond: useCallback((dareId, action) => act(dareId, () => daresApi.respond(dareId, action)), [act]),
|
||||
submit: useCallback((dareId) => act(dareId, () => daresApi.submit(dareId)), [act]),
|
||||
complete: useCallback((dareId) => act(dareId, () => daresApi.complete(dareId)), [act]),
|
||||
cancel: useCallback((dareId) => act(dareId, () => daresApi.cancel(dareId)), [act]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { daresApi } from "../../../lib/daresApi";
|
||||
|
||||
/**
|
||||
* Lightweight counterpart to `useDares` for the home tile: one request on focus
|
||||
* rather than the four-endpoint poll the full screen runs.
|
||||
*/
|
||||
export function useIncomingDareCount(enabled: boolean): number {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
daresApi
|
||||
.list("active")
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setCount(
|
||||
result.dares.filter(
|
||||
(dare) => dare.direction === "incoming" && dare.status === "pending",
|
||||
).length,
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled]),
|
||||
);
|
||||
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { DaresScreen, default } from "./DaresScreen";
|
||||
export { useDares } from "./hooks/useDares";
|
||||
export type { UseDaresReturn } from "./hooks/useDares";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,83 @@
|
||||
export type DareStatus =
|
||||
| "pending" | "active" | "submitted" | "completed" | "expired" | "declined" | "cancelled";
|
||||
|
||||
export interface DarePack {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
is_premium: boolean;
|
||||
is_explicit: boolean;
|
||||
sort_order: number;
|
||||
category_id: string | null;
|
||||
min_intensity: number | null;
|
||||
max_intensity: number | null;
|
||||
avg_intensity: number | null;
|
||||
dare_count: number;
|
||||
}
|
||||
|
||||
export interface DareItem {
|
||||
id: string;
|
||||
pack_id: string;
|
||||
text: string;
|
||||
intensity: number;
|
||||
suggested_duration_hours: number | null;
|
||||
}
|
||||
|
||||
export interface SentDare {
|
||||
id: string;
|
||||
couple_id: string;
|
||||
dare_id: string | null;
|
||||
text: string;
|
||||
intensity: number;
|
||||
is_custom: boolean;
|
||||
sender_id: string;
|
||||
recipient_id: string;
|
||||
direction: "incoming" | "outgoing";
|
||||
status: DareStatus;
|
||||
sender_notes: string | null;
|
||||
sent_at: string;
|
||||
accepted_at: string | null;
|
||||
submitted_at: string | null;
|
||||
completed_at: string | null;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export interface DareEntitlement {
|
||||
is_premium: boolean;
|
||||
can_send_custom: boolean;
|
||||
weekly_send_limit: number | null;
|
||||
sends_remaining: number | null;
|
||||
}
|
||||
|
||||
export interface DareCatalog {
|
||||
entitlement: DareEntitlement;
|
||||
packs: DarePack[];
|
||||
}
|
||||
|
||||
export interface DareStats {
|
||||
sent: number;
|
||||
received: number;
|
||||
completed_together: number;
|
||||
active: number;
|
||||
completed_by_me: number;
|
||||
completed_by_partner: number;
|
||||
}
|
||||
|
||||
export interface SendDarePayload {
|
||||
dare_id?: string;
|
||||
custom_dare_text?: string;
|
||||
custom_dare_intensity?: number;
|
||||
duration_hours?: number | null;
|
||||
sender_notes?: string | null;
|
||||
}
|
||||
|
||||
export const DURATION_OPTIONS: { label: string; hours: number | null }[] = [
|
||||
{ label: "1 hour", hours: 1 },
|
||||
{ label: "6 hours", hours: 6 },
|
||||
{ label: "12 hours", hours: 12 },
|
||||
{ label: "24 hours", hours: 24 },
|
||||
{ label: "3 days", hours: 72 },
|
||||
{ label: "1 week", hours: 168 },
|
||||
{ label: "No time limit", hours: null },
|
||||
];
|
||||
@@ -1,3 +1,4 @@
|
||||
// Features exports
|
||||
export * from './chat';
|
||||
export * from './dares';
|
||||
export * from './profile';
|
||||
@@ -0,0 +1,19 @@
|
||||
import { apiClient } from "./apiClient";
|
||||
import type { DareCatalog, DareItem, DareStats, SendDarePayload, SentDare } from "../features/dares/types";
|
||||
|
||||
const dare = (dareId: string) => `/v1/dares/${encodeURIComponent(dareId)}`;
|
||||
|
||||
export const daresApi = {
|
||||
catalog: () => apiClient.get<DareCatalog>("/v1/dares/packs"),
|
||||
packDares: (packId: string) =>
|
||||
apiClient.get<{ dares: DareItem[] }>(`/v1/dares/packs/${encodeURIComponent(packId)}/dares`),
|
||||
list: (filter: "active" | "history") =>
|
||||
apiClient.get<{ dares: SentDare[] }>(`/v1/dares?filter=${filter}`),
|
||||
stats: () => apiClient.get<DareStats>("/v1/dares/stats"),
|
||||
send: (payload: SendDarePayload) => apiClient.post<{ dare: SentDare }>("/v1/dares", payload),
|
||||
respond: (dareId: string, action: "accept" | "decline") =>
|
||||
apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/respond`, { action }),
|
||||
submit: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/submit`),
|
||||
complete: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/complete`),
|
||||
cancel: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/cancel`),
|
||||
};
|
||||
Reference in new issue
Block a user