mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
chore(staging): integrate verified changes
Combine the Android API 36 release, dares loop, RevenueCat documentation correction, and daily engagement features. Resolve migration ordering and preserve both dares expiry and streak reminder operations.
This commit is contained in:
commit
03443fe898
49 files changed
+3094
-569
No files matched your search
@@ -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