From 57501230cd1c26a5249464c7ccefafa43af90255 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 30 Aug 2026 19:38:09 +0100 Subject: [PATCH] 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 --- .../share/QuestionShareCard.test.tsx | 28 +++ .../components/share/QuestionShareCard.tsx | 158 ++++++++++++++++ .../components/share/SharePreviewModal.tsx | 177 ++---------------- apps/mobile/src/components/share/index.ts | 3 +- apps/mobile/src/lib/analytics.ts | 2 + 5 files changed, 208 insertions(+), 160 deletions(-) create mode 100644 apps/mobile/src/components/share/QuestionShareCard.test.tsx create mode 100644 apps/mobile/src/components/share/QuestionShareCard.tsx diff --git a/apps/mobile/src/components/share/QuestionShareCard.test.tsx b/apps/mobile/src/components/share/QuestionShareCard.test.tsx new file mode 100644 index 0000000..546a629 --- /dev/null +++ b/apps/mobile/src/components/share/QuestionShareCard.test.tsx @@ -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( + , + ); + + 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( + , + ); + + expect(queryByText(/join\//)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/components/share/QuestionShareCard.tsx b/apps/mobile/src/components/share/QuestionShareCard.tsx new file mode 100644 index 0000000..e74e526 --- /dev/null +++ b/apps/mobile/src/components/share/QuestionShareCard.tsx @@ -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 ( + + {/* Background - solid color matching what the user saw, or gradient fallback */} + {useGradient ? ( + + ) : ( + + )} + + {/* Subtle overlay for depth */} + + + {/* Header: wordmark + pack badge */} + + + + Sauci + + {packName ? ( + + {packName} + + ) : null} + + + {/* Question */} + + {question.text} + + + {/* Branded footer */} + + Play it with your partner + + sauci.app + + + + ); +} + +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", + }, +}); diff --git a/apps/mobile/src/components/share/SharePreviewModal.tsx b/apps/mobile/src/components/share/SharePreviewModal.tsx index b3fd6fe..bff969a 100644 --- a/apps/mobile/src/components/share/SharePreviewModal.tsx +++ b/apps/mobile/src/components/share/SharePreviewModal.tsx @@ -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(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', }} > - - {/* Background - solid color matching what the user saw, or gradient fallback */} - {useGradient ? ( - - ) : ( - - )} - - {/* Subtle overlay for depth */} - - - {/* Pack name badge */} - {packName && ( - - {packName} - - )} - - {/* Content */} - - - {question.text} - - - - {/* Branding with logo */} - - - sauci.app - - - ); -} - 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', - }, }); diff --git a/apps/mobile/src/components/share/index.ts b/apps/mobile/src/components/share/index.ts index 1b98015..3657f63 100644 --- a/apps/mobile/src/components/share/index.ts +++ b/apps/mobile/src/components/share/index.ts @@ -1 +1,2 @@ -export { SharePreviewModal } from './SharePreviewModal'; +export { SharePreviewModal } from './SharePreviewModal'; +export { QuestionShareCard } from './QuestionShareCard'; diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts index be45d04..bec04ab 100644 --- a/apps/mobile/src/lib/analytics.ts +++ b/apps/mobile/src/lib/analytics.ts @@ -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) =>