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:
jjbruntonandClaude Opus 5 committed 2026-08-28 20:03:11 +01:00
1 parent a7470310ae
commit ce0b188776
34 files changed
+2673 -242

No files matched your search

+5 -232
View File
@@ -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 />;
}
+4 -1
View File
@@ -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;
}
+4
View File
@@ -0,0 +1,4 @@
export { DaresScreen, default } from "./DaresScreen";
export { useDares } from "./hooks/useDares";
export type { UseDaresReturn } from "./hooks/useDares";
export * from "./types";
+83
View File
@@ -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
View File
@@ -1,3 +1,4 @@
// Features exports
export * from './chat';
export * from './dares';
export * from './profile';
+19
View File
@@ -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`),
};