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

@@ -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';