mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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>
256 lines
11 KiB
TypeScript
256 lines
11 KiB
TypeScript
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" },
|
|
});
|