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
+144 -93

No files matched your search

@@ -30,8 +30,6 @@ import {
Heart, Heart,
MessageCircle, MessageCircle,
UserPlus, UserPlus,
Image as ImageIcon,
Video as VideoIcon,
ExternalLink, ExternalLink,
} from 'lucide-react'; } from 'lucide-react';
@@ -546,7 +544,6 @@ export function UserActivityPage() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>User</TableHead> <TableHead>User</TableHead>
<TableHead>Message</TableHead>
<TableHead>Context</TableHead> <TableHead>Context</TableHead>
<TableHead>Time</TableHead> <TableHead>Time</TableHead>
<TableHead>Actions</TableHead> <TableHead>Actions</TableHead>
@@ -555,13 +552,13 @@ export function UserActivityPage() {
<TableBody> <TableBody>
{loading.messages ? ( {loading.messages ? (
<TableRow> <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> <div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
<TableRow> <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. No messages yet.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -569,21 +566,6 @@ export function UserActivityPage() {
messages.map((item) => ( messages.map((item) => (
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell>{renderUserLink(item.profile)}</TableCell> <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]"> <TableCell className="max-w-[200px]">
<span className="truncate block text-muted-foreground text-sm"> <span className="truncate block text-muted-foreground text-sm">
{item.match?.question?.text || 'Unknown match'} {item.match?.question?.text || 'Unknown match'}
+3 -62
View File
@@ -240,26 +240,6 @@ export default function MatchesScreen() {
entering={FadeInUp.duration(600).springify()} entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent} 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 */} {/* Description */}
<Text style={styles.emptyDescription}> <Text style={styles.emptyDescription}>
Answer questions together to discover what you both enjoy. Matches appear when you agree! Answer questions together to discover what you both enjoy. Matches appear when you agree!
@@ -268,15 +248,15 @@ export default function MatchesScreen() {
{/* Feature hints */} {/* Feature hints */}
<View style={styles.emptyFeatures}> <View style={styles.emptyFeatures}>
<View style={styles.emptyFeatureItem}> <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> <Text style={styles.emptyFeatureText}>Swipe right for yes</Text>
</View> </View>
<View style={styles.emptyFeatureItem}> <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> <Text style={styles.emptyFeatureText}>Match when you both agree</Text>
</View> </View>
<View style={styles.emptyFeatureItem}> <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> <Text style={styles.emptyFeatureText}>Chat about your matches</Text>
</View> </View>
</View> </View>
@@ -507,45 +487,6 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.xl, 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: { emptyDescription: {
...typography.body, ...typography.body,
color: colors.textSecondary, color: colors.textSecondary,
@@ -6,6 +6,34 @@ const corsHeaders = {
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", "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) => { Deno.serve(async (req) => {
// Handle CORS preflight // Handle CORS preflight
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
@@ -62,6 +90,14 @@ Deno.serve(async (req) => {
const coupleId = profile.couple_id; 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) // Get all match IDs for this couple (needed for storage cleanup)
const { data: matches } = await supabase const { data: matches } = await supabase
.from("matches") .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( return new Response(
JSON.stringify({ success: true, message: "Relationship data deleted successfully" }), JSON.stringify({ success: true, message: "Relationship data deleted successfully" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } } { headers: { ...corsHeaders, "Content-Type": "application/json" } }
+45 -11
View File
@@ -10,6 +10,34 @@ interface JoinCoupleBody {
invite_code: string; 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) => { Deno.serve(async (req) => {
// Handle CORS preflight // Handle CORS preflight
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
@@ -183,18 +211,13 @@ Deno.serve(async (req) => {
const coupleId = profile.couple_id; const coupleId = profile.couple_id;
// Count members in this couple // Get partner's push token BEFORE clearing couple_id
const { count, error: countError } = await supabase const { data: partnerProfile } = await supabase
.from("profiles") .from("profiles")
.select("*", { count: "exact", head: true }) .select("push_token")
.eq("couple_id", coupleId); .eq("couple_id", coupleId)
.neq("id", user.id)
if (countError) { .maybeSingle();
return new Response(
JSON.stringify({ error: "Failed to check couple members" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Remove ALL members from couple (both partners get unlinked) // Remove ALL members from couple (both partners get unlinked)
// This ensures the remaining partner isn't left in a "waiting" state // 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 // 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( return new Response(
JSON.stringify({ success: true, couple_deleted: true }), JSON.stringify({ success: true, couple_deleted: true }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } } { headers: { ...corsHeaders, "Content-Type": "application/json" } }
@@ -6,6 +6,34 @@ const corsHeaders = {
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", "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) => { Deno.serve(async (req) => {
// Handle CORS preflight // Handle CORS preflight
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
@@ -62,6 +90,14 @@ Deno.serve(async (req) => {
const coupleId = profile.couple_id; 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) // Get all match IDs for this couple (needed for storage cleanup)
const { data: matches } = await supabase const { data: matches } = await supabase
.from("matches") .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( return new Response(
JSON.stringify({ success: true, message: "Progress reset successfully" }), JSON.stringify({ success: true, message: "Progress reset successfully" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } } { headers: { ...corsHeaders, "Content-Type": "application/json" } }