mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
Merge branded question share card into staging
This commit is contained in:
commit
d86d57d5c2
5 files changed
+208
-160
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 { SharePreviewModal } from './SharePreviewModal';
|
||||
export { QuestionShareCard } from './QuestionShareCard';
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user