feat(share): branded question share card with share analytics

Replace the inline ShareableCard in SharePreviewModal with a designed
QuestionShareCard: Sauci logo + wordmark header, pack badge, and a
branded sauci.app footer pill on the captured story image. The card
deliberately never carries the couple join code - it is a private
pairing credential, not a referral link (a regression test enforces
this). Text-share fallbacks now carry a full https://sauci.app link,
and all three share paths fire a new question_shared analytics event
with the destination.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-30 19:38:09 +01:00
1 parent f52b8536be
commit 57501230cd
5 files changed
+207 -159

No files matched your search

@@ -0,0 +1,28 @@
import { render } from "@/test/test-utils";
import { QuestionShareCard } from "./QuestionShareCard";
describe("QuestionShareCard", () => {
it("renders the question, pack badge, and Sauci branding", () => {
const { getByText } = render(
<QuestionShareCard
question={{ text: "What's your idea of a perfect date night?" }}
packName="Date Night"
cardWidth={300}
/>,
);
expect(getByText("What's your idea of a perfect date night?")).toBeTruthy();
expect(getByText("Date Night")).toBeTruthy();
expect(getByText("Sauci")).toBeTruthy();
expect(getByText("sauci.app")).toBeTruthy();
expect(getByText(/play it with your partner/i)).toBeTruthy();
});
it("never renders a couple join link — the pairing code is private", () => {
const { queryByText } = render(
<QuestionShareCard question={{ text: "A question" }} cardWidth={300} />,
);
expect(queryByText(/join\//)).toBeNull();
});
});
@@ -0,0 +1,158 @@
import { Image, StyleSheet, Text, View } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { colors, gradients, radius, spacing, typography } from "../../theme";
const logo = require("../../../assets/logo.png");
interface QuestionShareCardProps {
question: {
text: string;
};
packName?: string;
cardColor?: string;
cardWidth: number;
}
/**
* The story card captured for outbound shares (Instagram Stories, Messages,
* share sheet). Carries the question and Sauci branding only — never the
* sharer's couple join code, which is a private pairing credential, not a
* referral link.
*/
export function QuestionShareCard({
question,
packName,
cardColor,
cardWidth,
}: QuestionShareCardProps) {
const useGradient = !cardColor;
return (
<View style={[styles.card, { width: cardWidth }]}>
{/* Background - solid color matching what the user saw, or gradient fallback */}
{useGradient ? (
<LinearGradient
colors={gradients.primary as [string, string]}
style={[StyleSheet.absoluteFill, { borderRadius: radius.xl }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
) : (
<View
style={[
StyleSheet.absoluteFill,
{ backgroundColor: cardColor, borderRadius: radius.xl },
]}
/>
)}
{/* Subtle overlay for depth */}
<View style={styles.overlay} />
{/* Header: wordmark + pack badge */}
<View style={styles.header}>
<View style={styles.wordmark}>
<Image source={logo} style={styles.wordmarkLogo} />
<Text style={styles.wordmarkText}>Sauci</Text>
</View>
{packName ? (
<View style={styles.packBadge}>
<Text style={styles.packBadgeText}>{packName}</Text>
</View>
) : null}
</View>
{/* Question */}
<View style={styles.content}>
<Text style={styles.questionText}>{question.text}</Text>
</View>
{/* Branded footer */}
<View style={styles.footer}>
<Text style={styles.footerPrompt}>Play it with your partner</Text>
<View style={styles.linkPill}>
<Text style={styles.linkText}>sauci.app</Text>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
aspectRatio: 0.7,
borderRadius: radius.xl,
overflow: "hidden",
padding: spacing.lg,
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.1)",
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
},
wordmark: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
},
wordmarkLogo: {
width: 22,
height: 22,
resizeMode: "contain",
},
wordmarkText: {
...typography.subhead,
color: colors.text,
fontWeight: "700",
},
packBadge: {
flexShrink: 1,
backgroundColor: "rgba(255, 255, 255, 0.2)",
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
packBadgeText: {
...typography.caption1,
color: colors.text,
fontWeight: "600",
},
content: {
flex: 1,
justifyContent: "center",
paddingVertical: spacing.xl,
},
questionText: {
...typography.title1,
color: colors.text,
fontWeight: "700",
lineHeight: 36,
},
footer: {
alignItems: "center",
gap: spacing.sm,
},
footerPrompt: {
...typography.caption1,
color: "rgba(255, 255, 255, 0.85)",
fontWeight: "600",
letterSpacing: 1,
textTransform: "uppercase",
},
linkPill: {
backgroundColor: "rgba(255, 255, 255, 0.95)",
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
},
linkText: {
...typography.subhead,
color: "#1a1a2e",
fontWeight: "700",
},
});
@@ -8,7 +8,6 @@ import {
Platform,
Share,
useWindowDimensions,
Image,
} from "react-native";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
@@ -22,10 +21,10 @@ import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
import { colors, gradients, radius, typography, spacing, blur } from "../../theme";
import { colors, typography, spacing, blur } from "../../theme";
import { shareToInstagramStories } from "../../lib/instagramShare";
const logo = require("../../../assets/logo.png");
import { Events } from "../../lib/analytics";
import { QuestionShareCard } from "./QuestionShareCard";
const MAX_CARD_WIDTH = 340;
@@ -55,6 +54,8 @@ export function SharePreviewModal({
const [capturedUri, setCapturedUri] = useState<string | null>(null);
const [isCapturing, setIsCapturing] = useState(false);
const shareMessage = `${question.text}\n\nPlay it with your partner on https://sauci.app`;
// Capture the card when modal becomes visible
useEffect(() => {
if (visible && shareViewRef.current) {
@@ -87,7 +88,9 @@ export function SharePreviewModal({
if (!capturedUri) return;
const shared = await shareToInstagramStories(capturedUri);
if (!shared) {
if (shared) {
Events.questionShared("instagram");
} else {
// Instagram Stories sharing unavailable - fall back to the share sheet
await handleMoreShare();
}
@@ -96,9 +99,8 @@ export function SharePreviewModal({
const handleMessagesShare = async () => {
if (!capturedUri) {
// Fallback to text
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
await Share.share({ message: shareMessage });
Events.questionShared("messages");
return;
}
@@ -109,22 +111,21 @@ export function SharePreviewModal({
mimeType: 'image/png',
UTI: 'public.png',
});
Events.questionShared("messages");
} else {
await handleMoreShare();
}
} catch (error) {
console.error('Messages share error:', error);
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
await Share.share({ message: shareMessage });
Events.questionShared("messages");
}
};
const handleMoreShare = async () => {
if (!capturedUri) {
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
await Share.share({ message: shareMessage });
Events.questionShared("more");
return;
}
@@ -136,10 +137,9 @@ export function SharePreviewModal({
dialogTitle: 'Share this question',
});
} else {
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
await Share.share({ message: shareMessage });
}
Events.questionShared("more");
} catch (error) {
console.error('Share error:', error);
}
@@ -202,7 +202,7 @@ export function SharePreviewModal({
result: 'tmpfile',
}}
>
<ShareableCard
<QuestionShareCard
question={question}
packName={packName}
cardWidth={cardWidth}
@@ -287,69 +287,6 @@ function ShareActionButton({
);
}
function ShareableCard({
question,
packName,
cardWidth,
cardColor,
}: {
question: {
text: string;
partner_text?: string | null;
is_two_part?: boolean;
};
packName?: string;
cardWidth: number;
cardColor?: string;
}) {
// Use the card color if provided, otherwise fall back to primary gradient
const useGradient = !cardColor;
return (
<View style={[styles.shareableCard, { width: cardWidth }]}>
{/* Background - solid color matching what the user saw, or gradient fallback */}
{useGradient ? (
<LinearGradient
colors={gradients.primary as [string, string]}
style={[StyleSheet.absoluteFill, { borderRadius: radius.xl }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
) : (
<View
style={[
StyleSheet.absoluteFill,
{ backgroundColor: cardColor, borderRadius: radius.xl }
]}
/>
)}
{/* Subtle overlay for depth */}
<View style={styles.cardOverlay} />
{/* Pack name badge */}
{packName && (
<View style={styles.packBadge}>
<Text style={styles.packBadgeText}>{packName}</Text>
</View>
)}
{/* Content */}
<View style={styles.shareableContent}>
<Text style={styles.questionText}>
{question.text}
</Text>
</View>
{/* Branding with logo */}
<View style={styles.branding}>
<Image source={logo} style={styles.brandingLogo} />
<Text style={styles.brandingText}>sauci.app</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
@@ -418,82 +355,4 @@ const styles = StyleSheet.create({
color: colors.text,
fontWeight: '500',
},
// Shareable card styles
shareableCard: {
aspectRatio: 0.7,
borderRadius: radius.xl,
overflow: 'hidden',
padding: spacing.lg,
},
cardOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.1)',
},
packBadge: {
alignSelf: 'flex-start',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
packBadgeText: {
...typography.caption1,
color: colors.text,
fontWeight: '600',
},
shareableContent: {
flex: 1,
justifyContent: 'center',
paddingVertical: spacing.xl,
},
questionText: {
...typography.title1,
color: colors.text,
fontWeight: '700',
lineHeight: 36,
},
questionTextSmaller: {
...typography.title2,
lineHeight: 30,
},
partnerSection: {
marginTop: spacing.lg,
},
partnerDivider: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
gap: spacing.sm,
},
partnerDividerLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
},
partnerLabel: {
...typography.caption1,
color: 'rgba(255, 255, 255, 0.7)',
fontWeight: '500',
},
partnerText: {
...typography.subhead,
color: 'rgba(255, 255, 255, 0.8)',
fontStyle: 'italic',
},
branding: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
},
brandingLogo: {
width: 24,
height: 24,
resizeMode: 'contain',
},
brandingText: {
...typography.subhead,
color: colors.text,
fontWeight: '600',
},
});
@@ -1 +1,2 @@
export { SharePreviewModal } from './SharePreviewModal';
export { QuestionShareCard } from './QuestionShareCard';
+2
View File
@@ -214,6 +214,8 @@ export const Events = {
questionAnswered: (answer: string, packId?: string) =>
logEvent("question_answered", { answer, pack_id: packId ?? "unknown" }),
questionSkipped: () => logEvent("question_skipped"),
questionShared: (destination: "instagram" | "messages" | "more") =>
logEvent("question_shared", { destination }),
matchCreated: (matchType: string) => logEvent("match_created", { match_type: matchType }),
allQuestionsExhausted: () => logEvent("all_questions_exhausted"),
pendingQuestionsPromptViewed: (count: number) =>