mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
feat(dares): ship the dares loop to mobile
Completes the half-built dares feature: send a dare, respond, report done, confirm, and get a push notification on every transition. Backend (apps/api): - New `dares` domain with couple-shared premium entitlement, a free weekly send allowance, and server-enforced actor/state rules on every transition. - `submitted` status so the recipient reports completion and the sender confirms, rather than the sender guessing. - Notifications ride the existing operations outbox via a trigger on `sent_dares`. Push copy is generic; store compliance forbids dare text in a preview and a test asserts no payload leaks it. - Worker expiry sweep now covers `pending` as well as `active`/`submitted`; an invitation nobody opened previously stayed open forever. Fixes two latent defects: - Deleting any dare that had been sent aborted with a check violation, because `dare_id` is ON DELETE SET NULL while `sent_dares_dare_or_custom_check` required it. Dare text is now snapshotted at send time, which also keeps a couple's history stable across catalogue edits. - The free send allowance was raceable: concurrent requests all read the same count and every one succeeded. The count and insert are now serialized on the sender's profile row. Also ports `content_status` review metadata to the API catalogue, which had no store-safety gating at all, and mirrors the review decision already enforced on the legacy catalogue. Mobile: dares screen (active/send/history), send sheet with duration presets, home tile badging incoming dares, paywall on both proactive and 402 paths. Verified: lint, typecheck, 379 unit tests, build, 71 integration tests against local PostgreSQL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a7470310ae
commit
ce0b188776
34 files changed
+2673
-242
No files matched your search
@@ -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";
|
||||
Reference in new issue
Block a user