Add lifecycle push notifications and refine UI

Send push notifications when partner disconnects, resets progress, or deletes relationship.
Update Matches screen with new styling and remove legacy premium elements.
Remove message content preview from admin User Activity page.
This commit is contained in:
jjbrunton committed 2026-01-03 18:58:57 +00:00
1 parent a567fba349
commit 994dd2a295
5 files changed
+174 -123

No files matched your search

@@ -30,8 +30,6 @@ import {
Heart,
MessageCircle,
UserPlus,
Image as ImageIcon,
Video as VideoIcon,
ExternalLink,
} from 'lucide-react';
@@ -546,7 +544,6 @@ export function UserActivityPage() {
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Message</TableHead>
<TableHead>Context</TableHead>
<TableHead>Time</TableHead>
<TableHead>Actions</TableHead>
@@ -555,13 +552,13 @@ export function UserActivityPage() {
<TableBody>
{loading.messages ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8">
<TableCell colSpan={4} className="text-center py-8">
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
</TableCell>
</TableRow>
) : messages.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
No messages yet.
</TableCell>
</TableRow>
@@ -569,21 +566,6 @@ export function UserActivityPage() {
messages.map((item) => (
<TableRow key={item.id}>
<TableCell>{renderUserLink(item.profile)}</TableCell>
<TableCell className="max-w-[300px]">
{item.media_path ? (
<span className="inline-flex items-center gap-1 text-muted-foreground">
{item.media_type === 'video' ? (
<><VideoIcon className="h-4 w-4" /> [Video]</>
) : (
<><ImageIcon className="h-4 w-4" /> [Image]</>
)}
</span>
) : (
<span className="truncate block">
{item.content || <span className="text-muted-foreground italic">Empty message</span>}
</span>
)}
</TableCell>
<TableCell className="max-w-[200px]">
<span className="truncate block text-muted-foreground text-sm">
{item.match?.question?.text || 'Unknown match'}
+33 -92
View File
@@ -11,8 +11,8 @@ import Animated, {
FadeInRight,
FadeInUp,
} from "react-native-reanimated";
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { useAmbientOrbAnimation } from "../../src/hooks";
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { useAmbientOrbAnimation } from "../../src/hooks";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
import { MatchesTutorial } from "../../src/components/tutorials";
import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen";
@@ -29,11 +29,11 @@ export default function MatchesScreen() {
const { matches, fetchMatches, markAllAsSeen } = useMatchStore();
const { user } = useAuthStore();
const router = useRouter();
const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH;
const [showTutorial, setShowTutorial] = useState(false);
// Ambient orb breathing animations
const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH;
const [showTutorial, setShowTutorial] = useState(false);
// Ambient orb breathing animations
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
// Check if tutorial should be shown when screen is focused or matches change
@@ -101,15 +101,15 @@ export default function MatchesScreen() {
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[`${ACCENT_RGBA}0.06)`, 'transparent']}
style={styles.cardSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={styles.matchRow}>
{/* Top silk highlight */}
<LinearGradient
colors={[`${ACCENT_RGBA}0.06)`, 'transparent']}
style={styles.cardSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={styles.matchRow}>
{/* Premium icon container */}
<View style={[
styles.iconContainerPremium,
@@ -240,26 +240,6 @@ export default function MatchesScreen() {
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
>
{/* Premium icon */}
<View style={styles.emptyIconContainerPremium}>
<Ionicons name="heart-outline" size={36} color={ROSE} />
</View>
{/* Title section */}
<Text style={styles.emptyLabel}>BEGIN</Text>
<Text style={styles.emptyTitlePremium}>No Matches Yet</Text>
{/* Decorative separator */}
<DecorativeSeparator variant="rose" />
{/* Status badge */}
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.emptyBadge}
>
<Text style={styles.emptyBadgeText}>START EXPLORING</Text>
</Animated.View>
{/* Description */}
<Text style={styles.emptyDescription}>
Answer questions together to discover what you both enjoy. Matches appear when you agree!
@@ -268,15 +248,15 @@ export default function MatchesScreen() {
{/* Feature hints */}
<View style={styles.emptyFeatures}>
<View style={styles.emptyFeatureItem}>
<Ionicons name="heart" size={16} color={ROSE} />
<Ionicons name="heart" size={16} color={ACCENT} />
<Text style={styles.emptyFeatureText}>Swipe right for yes</Text>
</View>
<View style={styles.emptyFeatureItem}>
<Ionicons name="sparkles" size={16} color={ROSE} />
<Ionicons name="sparkles" size={16} color={ACCENT} />
<Text style={styles.emptyFeatureText}>Match when you both agree</Text>
</View>
<View style={styles.emptyFeatureItem}>
<Ionicons name="chatbubbles-outline" size={16} color={ROSE} />
<Ionicons name="chatbubbles-outline" size={16} color={ACCENT} />
<Text style={styles.emptyFeatureText}>Chat about your matches</Text>
</View>
</View>
@@ -295,13 +275,13 @@ export default function MatchesScreen() {
/>
</View>
{/* Matches Tutorial Overlay */}
{showTutorial && (
<MatchesTutorial onComplete={handleTutorialComplete} />
)}
</GradientBackground>
);
}
{/* Matches Tutorial Overlay */}
{showTutorial && (
<MatchesTutorial onComplete={handleTutorialComplete} />
)}
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
@@ -387,13 +367,13 @@ const styles = StyleSheet.create({
maxWidth: MAX_CONTENT_WIDTH,
},
// Premium Match Card
matchCardPremium: {
marginBottom: spacing.sm,
borderRadius: radius.lg,
overflow: 'hidden',
padding: spacing.md,
},
cardSilkHighlight: {
matchCardPremium: {
marginBottom: spacing.sm,
borderRadius: radius.lg,
overflow: 'hidden',
padding: spacing.md,
},
cardSilkHighlight: {
position: 'absolute',
top: 0,
left: 0,
@@ -507,45 +487,6 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.xl,
},
emptyIconContainerPremium: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: `${ROSE_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ROSE_RGBA}0.2)`,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.xl,
},
emptyLabel: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: ROSE,
textAlign: 'center',
marginBottom: spacing.xs,
},
emptyTitlePremium: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
emptyBadge: {
backgroundColor: `${ROSE_RGBA}0.1)`,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: `${ROSE_RGBA}0.2)`,
marginBottom: spacing.xl,
},
emptyBadgeText: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: ROSE,
},
emptyDescription: {
...typography.body,
color: colors.textSecondary,
@@ -6,6 +6,34 @@ const corsHeaders = {
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
interface ExpoPushMessage {
to: string;
title: string;
body: string;
sound?: string;
data?: Record<string, unknown>;
}
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
if (messages.length === 0) return;
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(messages),
});
if (!response.ok) {
const error = await response.text();
console.error("Expo push error:", error);
}
return response.json();
}
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
@@ -62,6 +90,14 @@ Deno.serve(async (req) => {
const coupleId = profile.couple_id;
// Get partner's push token BEFORE clearing data
const { data: partnerProfile } = await supabase
.from("profiles")
.select("push_token")
.eq("couple_id", coupleId)
.neq("id", user.id)
.maybeSingle();
// Get all match IDs for this couple (needed for storage cleanup)
const { data: matches } = await supabase
.from("matches")
@@ -112,6 +148,17 @@ Deno.serve(async (req) => {
);
}
// Notify partner about the deletion
if (partnerProfile?.push_token) {
await sendExpoPushNotifications([{
to: partnerProfile.push_token,
title: "Relationship Deleted",
body: "Your partner has deleted all shared data. You can start fresh with a new partner anytime.",
sound: "default",
data: { type: "relationship_deleted" },
}]);
}
return new Response(
JSON.stringify({ success: true, message: "Relationship data deleted successfully" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
+45 -11
View File
@@ -10,6 +10,34 @@ interface JoinCoupleBody {
invite_code: string;
}
interface ExpoPushMessage {
to: string;
title: string;
body: string;
sound?: string;
data?: Record<string, unknown>;
}
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
if (messages.length === 0) return;
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(messages),
});
if (!response.ok) {
const error = await response.text();
console.error("Expo push error:", error);
}
return response.json();
}
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
@@ -183,18 +211,13 @@ Deno.serve(async (req) => {
const coupleId = profile.couple_id;
// Count members in this couple
const { count, error: countError } = await supabase
// Get partner's push token BEFORE clearing couple_id
const { data: partnerProfile } = await supabase
.from("profiles")
.select("*", { count: "exact", head: true })
.eq("couple_id", coupleId);
if (countError) {
return new Response(
JSON.stringify({ error: "Failed to check couple members" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
.select("push_token")
.eq("couple_id", coupleId)
.neq("id", user.id)
.maybeSingle();
// Remove ALL members from couple (both partners get unlinked)
// This ensures the remaining partner isn't left in a "waiting" state
@@ -221,6 +244,17 @@ Deno.serve(async (req) => {
// Don't fail the request, users already unlinked
}
// Notify partner about the unpair
if (partnerProfile?.push_token) {
await sendExpoPushNotifications([{
to: partnerProfile.push_token,
title: "Partner Disconnected",
body: "Your partner has ended the connection. You can invite a new partner anytime.",
sound: "default",
data: { type: "unpair" },
}]);
}
return new Response(
JSON.stringify({ success: true, couple_deleted: true }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
@@ -6,6 +6,34 @@ const corsHeaders = {
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
interface ExpoPushMessage {
to: string;
title: string;
body: string;
sound?: string;
data?: Record<string, unknown>;
}
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
if (messages.length === 0) return;
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(messages),
});
if (!response.ok) {
const error = await response.text();
console.error("Expo push error:", error);
}
return response.json();
}
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
@@ -62,6 +90,14 @@ Deno.serve(async (req) => {
const coupleId = profile.couple_id;
// Get partner's push token BEFORE clearing data
const { data: partnerProfile } = await supabase
.from("profiles")
.select("push_token")
.eq("couple_id", coupleId)
.neq("id", user.id)
.maybeSingle();
// Get all match IDs for this couple (needed for storage cleanup)
const { data: matches } = await supabase
.from("matches")
@@ -112,6 +148,17 @@ Deno.serve(async (req) => {
);
}
// Notify partner about the progress reset
if (partnerProfile?.push_token) {
await sendExpoPushNotifications([{
to: partnerProfile.push_token,
title: "Progress Reset",
body: "Your partner has reset all progress. Your matches and chats have been cleared to start fresh.",
sound: "default",
data: { type: "progress_reset" },
}]);
}
return new Response(
JSON.stringify({ success: true, message: "Progress reset successfully" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }