From 8e83cc0d85e9056d69192e8e8fbac37b104734d2 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 30 Aug 2026 19:21:56 +0100 Subject: [PATCH] feat(admin): add support views for dares, quiz, subscriptions, couples, and live draw Give support read-only visibility into purchase history, streak locality fields, notification preferences, the dares loop, couples quiz sessions, invite-code lookup, and recent live-draw activity, all backed by resources already allow-listed in the admin repository. --- apps/admin/src/App.tsx | 27 ++ .../src/components/content/StrokeCanvas.tsx | 85 +++++ apps/admin/src/components/layout/Sidebar.tsx | 21 ++ apps/admin/src/lib/couples.test.ts | 39 ++ apps/admin/src/lib/couples.ts | 35 ++ apps/admin/src/lib/sentDares.test.ts | 33 ++ apps/admin/src/lib/sentDares.ts | 38 ++ .../pages/activity/LiveDrawSessionsPage.tsx | 222 +++++++++++ .../src/pages/activity/SentDaresPage.tsx | 289 +++++++++++++++ apps/admin/src/pages/users/CouplesPage.tsx | 200 ++++++++++ apps/admin/src/pages/users/UserDetailPage.tsx | 349 ++++++++++++++---- 11 files changed, 1261 insertions(+), 77 deletions(-) create mode 100644 apps/admin/src/components/content/StrokeCanvas.tsx create mode 100644 apps/admin/src/lib/couples.test.ts create mode 100644 apps/admin/src/lib/couples.ts create mode 100644 apps/admin/src/lib/sentDares.test.ts create mode 100644 apps/admin/src/lib/sentDares.ts create mode 100644 apps/admin/src/pages/activity/LiveDrawSessionsPage.tsx create mode 100644 apps/admin/src/pages/activity/SentDaresPage.tsx create mode 100644 apps/admin/src/pages/users/CouplesPage.tsx diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index fa1c980..062dc7f 100755 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -14,6 +14,7 @@ import { DaresPage } from '@/pages/content/DaresPage'; import { QuizQuestionsPage } from '@/pages/content/QuizQuestionsPage'; import { UsersPage } from '@/pages/users/UsersPage'; import { UserDetailPage } from '@/pages/users/UserDetailPage'; +import { CouplesPage } from '@/pages/users/CouplesPage'; import { MatchChatPage } from '@/pages/users/MatchChatPage'; import { AuditLogsPage } from '@/pages/AuditLogsPage'; import { AdminsPage } from '@/pages/admins/AdminsPage'; @@ -21,6 +22,8 @@ import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage'; import { FeedbackPage } from '@/pages/FeedbackPage'; import { UsageInsightsPage } from '@/pages/UsageInsightsPage'; import { UserActivityPage } from '@/pages/activity/UserActivityPage'; +import { SentDaresPage } from '@/pages/activity/SentDaresPage'; +import { LiveDrawSessionsPage } from '@/pages/activity/LiveDrawSessionsPage'; import { AiSettingsPage } from '@/pages/AiSettingsPage'; import { AppSettingsPage } from '@/pages/AppSettingsPage'; import { FlaggedMessagesPage } from '@/pages/FlaggedMessagesPage'; @@ -151,6 +154,14 @@ function App() { } /> + + + + } + /> } /> + + + + } + /> + + + + } + /> {/* System */} (null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const w = canvas.clientWidth; + const h = canvas.clientHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.scale(dpr, dpr); + + ctx.fillStyle = STROKE_CANVAS_BACKGROUND; + ctx.fillRect(0, 0, w, h); + + for (const stroke of strokes) { + if (stroke.points.length === 0) continue; + ctx.strokeStyle = stroke.isEraser ? STROKE_CANVAS_BACKGROUND : stroke.color; + ctx.lineWidth = stroke.width; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + + const first = stroke.points[0]; + ctx.moveTo(first.x * w, first.y * h); + + if (stroke.points.length === 1) { + ctx.lineTo(first.x * w + 0.1, first.y * h + 0.1); + } else { + for (let i = 1; i < stroke.points.length; i++) { + const prev = stroke.points[i - 1]; + const curr = stroke.points[i]; + const midX = ((prev.x + curr.x) / 2) * w; + const midY = ((prev.y + curr.y) / 2) * h; + + if (i === 1) { + ctx.lineTo(midX, midY); + } else { + ctx.quadraticCurveTo(prev.x * w, prev.y * h, midX, midY); + } + } + const last = stroke.points[stroke.points.length - 1]; + ctx.lineTo(last.x * w, last.y * h); + } + ctx.stroke(); + } + }, [strokes]); + + return ( +
+ +
+ ); +} diff --git a/apps/admin/src/components/layout/Sidebar.tsx b/apps/admin/src/components/layout/Sidebar.tsx index a996916..22c537a 100644 --- a/apps/admin/src/components/layout/Sidebar.tsx +++ b/apps/admin/src/components/layout/Sidebar.tsx @@ -22,6 +22,9 @@ import { Tags, Zap, HelpCircle, + Heart, + Swords, + Pencil, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; @@ -90,6 +93,24 @@ const usersNav: NavItem[] = [ icon: , requiredPermission: PERMISSION_KEYS.VIEW_ACTIVITY, }, + { + label: 'Sent Dares', + href: '/activity/sent-dares', + icon: , + requiredPermission: PERMISSION_KEYS.VIEW_ACTIVITY, + }, + { + label: 'Live Draw Sessions', + href: '/activity/live-draw', + icon: , + requiredPermission: PERMISSION_KEYS.VIEW_USERS, + }, + { + label: 'Couples', + href: '/couples', + icon: , + requiredPermission: PERMISSION_KEYS.VIEW_USERS, + }, { label: 'Usage Insights', href: '/usage-insights', diff --git a/apps/admin/src/lib/couples.test.ts b/apps/admin/src/lib/couples.test.ts new file mode 100644 index 0000000..465aa30 --- /dev/null +++ b/apps/admin/src/lib/couples.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { buildCoupleSearchFilter, coupleStatusFromMemberCount } from './couples'; + +describe('coupleStatusFromMemberCount', () => { + it('treats zero or one member as pending', () => { + expect(coupleStatusFromMemberCount(0)).toBe('pending'); + expect(coupleStatusFromMemberCount(1)).toBe('pending'); + }); + + it('treats two or more members as paired', () => { + expect(coupleStatusFromMemberCount(2)).toBe('paired'); + expect(coupleStatusFromMemberCount(3)).toBe('paired'); + }); +}); + +describe('buildCoupleSearchFilter', () => { + it('returns null for an empty or blank search', () => { + expect(buildCoupleSearchFilter('')).toBeNull(); + expect(buildCoupleSearchFilter(' ')).toBeNull(); + }); + + it('matches a uuid-shaped search as an exact id filter', () => { + expect(buildCoupleSearchFilter('a1b2c3d4-1111-1111-1111-111111111111')).toEqual({ + column: 'id', op: 'eq', value: 'a1b2c3d4-1111-1111-1111-111111111111', + }); + }); + + it('treats any other search as a partial invite code match', () => { + expect(buildCoupleSearchFilter('SUNSET42')).toEqual({ + column: 'invite_code', op: 'ilike', value: '%SUNSET42%', + }); + }); + + it('trims surrounding whitespace before deciding the filter shape', () => { + expect(buildCoupleSearchFilter(' sunset42 ')).toEqual({ + column: 'invite_code', op: 'ilike', value: '%sunset42%', + }); + }); +}); diff --git a/apps/admin/src/lib/couples.ts b/apps/admin/src/lib/couples.ts new file mode 100644 index 0000000..ab9d086 --- /dev/null +++ b/apps/admin/src/lib/couples.ts @@ -0,0 +1,35 @@ +// Couples do not carry an explicit "paired" flag (see apps/api/src/domains/couples/schema.ts). +// Pairing is inferred from how many profiles currently reference the couple id, the same +// approach UsersPage already uses inline for the couple badge on a profile row. + +export type CoupleStatus = 'paired' | 'pending'; + +/** + * Derive whether a couple is fully paired from the number of profiles that + * reference it. A couple starts with one member (the creator) and becomes + * paired once a partner joins via invite code. + */ +export function coupleStatusFromMemberCount(memberCount: number): CoupleStatus { + return memberCount >= 2 ? 'paired' : 'pending'; +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export type CoupleSearchFilter = + | { column: 'id'; op: 'eq'; value: string } + | { column: 'invite_code'; op: 'ilike'; value: string } + | null; + +/** + * Build the admin query filter for the couples search box. A UUID-shaped + * search term is treated as an exact id lookup; anything else is matched + * against the invite code with a case-insensitive partial match. + */ +export function buildCoupleSearchFilter(rawSearch: string): CoupleSearchFilter { + const search = rawSearch.trim(); + if (!search) return null; + if (UUID_PATTERN.test(search)) { + return { column: 'id', op: 'eq', value: search }; + } + return { column: 'invite_code', op: 'ilike', value: `%${search}%` }; +} diff --git a/apps/admin/src/lib/sentDares.test.ts b/apps/admin/src/lib/sentDares.test.ts new file mode 100644 index 0000000..c19cd29 --- /dev/null +++ b/apps/admin/src/lib/sentDares.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { isSentDareOverdue, SENT_DARE_STATUS_LABELS } from './sentDares'; + +describe('SENT_DARE_STATUS_LABELS', () => { + it('has a label for every documented status', () => { + for (const status of ['pending', 'active', 'submitted', 'completed', 'expired', 'declined', 'cancelled'] as const) { + expect(SENT_DARE_STATUS_LABELS[status].label).toBeTruthy(); + } + }); +}); + +describe('isSentDareOverdue', () => { + const now = new Date('2026-08-30T12:00:00Z'); + + it('is false when there is no expiry', () => { + expect(isSentDareOverdue('pending', null, now)).toBe(false); + }); + + it('is false for a terminal status even if expiry has passed', () => { + expect(isSentDareOverdue('completed', '2026-08-01T00:00:00Z', now)).toBe(false); + expect(isSentDareOverdue('expired', '2026-08-01T00:00:00Z', now)).toBe(false); + }); + + it('is false for an open status whose expiry has not passed', () => { + expect(isSentDareOverdue('active', '2026-09-01T00:00:00Z', now)).toBe(false); + }); + + it('is true for an open status whose expiry has already passed', () => { + expect(isSentDareOverdue('pending', '2026-08-01T00:00:00Z', now)).toBe(true); + expect(isSentDareOverdue('active', '2026-08-01T00:00:00Z', now)).toBe(true); + expect(isSentDareOverdue('submitted', '2026-08-01T00:00:00Z', now)).toBe(true); + }); +}); diff --git a/apps/admin/src/lib/sentDares.ts b/apps/admin/src/lib/sentDares.ts new file mode 100644 index 0000000..75c2d77 --- /dev/null +++ b/apps/admin/src/lib/sentDares.ts @@ -0,0 +1,38 @@ +// sent_dares status lifecycle mirrors the check constraint added in +// apps/api/drizzle/0013_dares_loop.sql: pending -> active -> submitted -> completed, +// with expired/declined/cancelled as terminal side branches. + +export const SENT_DARE_STATUSES = [ + 'pending', 'active', 'submitted', 'completed', 'expired', 'declined', 'cancelled', +] as const; + +export type SentDareStatus = typeof SENT_DARE_STATUSES[number]; + +export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline'; + +export const SENT_DARE_STATUS_LABELS: Record = { + pending: { label: 'Pending', variant: 'secondary' }, + active: { label: 'Active', variant: 'default' }, + submitted: { label: 'Submitted', variant: 'default' }, + completed: { label: 'Completed', variant: 'outline' }, + expired: { label: 'Expired', variant: 'destructive' }, + declined: { label: 'Declined', variant: 'destructive' }, + cancelled: { label: 'Cancelled', variant: 'outline' }, +}; + +const OPEN_STATUSES: ReadonlySet = new Set(['pending', 'active', 'submitted']); + +/** + * A dare is overdue when it is still in an open state but its expiry has + * already passed. The worker sweeps these to `expired` asynchronously, so the + * admin view can lag the true status briefly; this flags that gap for support. + */ +export function isSentDareOverdue( + status: string, + expiresAt: string | null, + now: Date = new Date(), +): boolean { + if (!expiresAt) return false; + if (!OPEN_STATUSES.has(status as SentDareStatus)) return false; + return new Date(expiresAt).getTime() < now.getTime(); +} diff --git a/apps/admin/src/pages/activity/LiveDrawSessionsPage.tsx b/apps/admin/src/pages/activity/LiveDrawSessionsPage.tsx new file mode 100644 index 0000000..3845996 --- /dev/null +++ b/apps/admin/src/pages/activity/LiveDrawSessionsPage.tsx @@ -0,0 +1,222 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { adminData } from '@/lib/adminApi'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { PaginationControls } from '@/components/ui/pagination'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Pencil, Eye } from 'lucide-react'; +import { format } from 'date-fns'; +import { toast } from 'sonner'; +import { StrokeCanvas, type StrokeSegment } from '@/components/content/StrokeCanvas'; + +interface LiveDrawSession { + couple_id: string; + revision: number; + updated_by: string; + created_at: string; + updated_at: string; +} + +interface Profile { + id: string; + name: string | null; + email: string | null; + couple_id: string | null; +} + +export function LiveDrawSessionsPage() { + const [sessions, setSessions] = useState([]); + const [membersByCouple, setMembersByCouple] = useState>({}); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const [total, setTotal] = useState(0); + + const [selectedCoupleId, setSelectedCoupleId] = useState(null); + const [strokes, setStrokes] = useState([]); + const [strokesLoading, setStrokesLoading] = useState(false); + + const fetchSessions = useCallback(async () => { + setLoading(true); + try { + const from = (page - 1) * pageSize; + const to = from + pageSize - 1; + + const { data, error, count } = await adminData + .from('live_draw_sessions') + .select('couple_id, revision, updated_by, created_at, updated_at', { count: 'exact' }) + .order('updated_at', { ascending: false }) + .range(from, to); + if (error) throw error; + + const rows = data || []; + setSessions(rows); + setTotal(count || 0); + + const coupleIds = rows.map((row) => row.couple_id); + if (coupleIds.length > 0) { + const { data: profiles } = await adminData + .from('profiles') + .select('id, name, email, couple_id') + .in('couple_id', coupleIds); + const grouped: Record = {}; + (profiles || []).forEach((profile) => { + if (!profile.couple_id) return; + (grouped[profile.couple_id] ??= []).push(profile); + }); + setMembersByCouple(grouped); + } else { + setMembersByCouple({}); + } + } catch (error) { + console.error('Failed to load live draw sessions:', error); + toast.error('Failed to load live draw sessions'); + } finally { + setLoading(false); + } + }, [page, pageSize]); + + useEffect(() => { fetchSessions(); }, [fetchSessions]); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + if (page > totalPages) setPage(totalPages); + }, [page, pageSize, total]); + + const previewSession = async (coupleId: string) => { + setSelectedCoupleId(coupleId); + setStrokesLoading(true); + setStrokes([]); + try { + const { data, error } = await adminData + .from('live_draw_sessions') + .select('strokes') + .eq('couple_id', coupleId) + .maybeSingle(); + if (error) throw error; + setStrokes((data?.strokes as StrokeSegment[]) || []); + } catch (error) { + console.error('Failed to load drawing:', error); + toast.error('Failed to load drawing'); + } finally { + setStrokesLoading(false); + } + }; + + const renderMembers = (coupleId: string) => { + const members = membersByCouple[coupleId] || []; + if (members.length === 0) return Unknown couple; + return ( +
+ {members.map((member) => ( + + {member.name || member.email || member.id} + + ))} +
+ ); + }; + + return ( +
+
+

+ + Live Draw Sessions +

+

+ Recent live-draw activity across couples, most recently updated first +

+
+ + + + + + Couple + Revision + Created + Last Updated + Actions + + + + {loading ? ( + + +
+
+
+ ) : sessions.length === 0 ? ( + + + No live draw sessions yet. + + + ) : ( + sessions.map((session) => ( + + {renderMembers(session.couple_id)} + {session.revision} + + {format(new Date(session.created_at), 'MMM d, yyyy')} + + + {format(new Date(session.updated_at), 'MMM d, yyyy HH:mm')} + + + + + + )) + )} +
+
+
+ + {total > 0 && ( + { setPage(1); setPageSize(size); }} + /> + )} + + {selectedCoupleId && ( + + + Drawing Preview + Read-only snapshot of the couple's shared canvas + + + {strokesLoading ? ( + + ) : strokes.length === 0 ? ( +
+ +

No strokes to show

+
+ ) : ( + + )} +
+
+ )} +
+ ); +} diff --git a/apps/admin/src/pages/activity/SentDaresPage.tsx b/apps/admin/src/pages/activity/SentDaresPage.tsx new file mode 100644 index 0000000..8503efd --- /dev/null +++ b/apps/admin/src/pages/activity/SentDaresPage.tsx @@ -0,0 +1,289 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { adminData } from '@/lib/adminApi'; +import { Badge } from '@/components/ui/badge'; +import { Card } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { PaginationControls } from '@/components/ui/pagination'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Swords, Eye, MessageSquare } from 'lucide-react'; +import { format } from 'date-fns'; +import { toast } from 'sonner'; +import { SENT_DARE_STATUS_LABELS, SENT_DARE_STATUSES, isSentDareOverdue, type SentDareStatus } from '@/lib/sentDares'; + +interface Profile { + id: string; + name: string | null; + email: string | null; +} + +interface SentDare { + id: string; + couple_id: string; + dare_text_snapshot: string; + custom_dare_text: string | null; + dare_intensity_snapshot: number; + sender_id: string; + recipient_id: string; + status: string; + sent_at: string; + expires_at: string | null; +} + +interface DareMessage { + id: string; + sender_id: string; + content: string; + created_at: string; + read_at: string | null; +} + +const STATUS_FILTER_OPTIONS: Array<'all' | SentDareStatus> = ['all', ...SENT_DARE_STATUSES]; + +export function SentDaresPage() { + const [dares, setDares] = useState([]); + const [profilesById, setProfilesById] = useState>({}); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState<'all' | SentDareStatus>('all'); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const [total, setTotal] = useState(0); + + const [selectedDare, setSelectedDare] = useState(null); + const [messages, setMessages] = useState([]); + const [messagesLoading, setMessagesLoading] = useState(false); + + const fetchDares = useCallback(async () => { + setLoading(true); + try { + const from = (page - 1) * pageSize; + const to = from + pageSize - 1; + + let query = adminData + .from('sent_dares') + .select('id, couple_id, dare_text_snapshot, custom_dare_text, dare_intensity_snapshot, sender_id, recipient_id, status, sent_at, expires_at', { count: 'exact' }) + .order('sent_at', { ascending: false }) + .range(from, to); + + if (statusFilter !== 'all') { + query = query.eq('status', statusFilter); + } + + const { data, error, count } = await query; + if (error) throw error; + + const rows = data || []; + setDares(rows); + setTotal(count || 0); + + const userIds = [...new Set(rows.flatMap((row) => [row.sender_id, row.recipient_id]))]; + if (userIds.length > 0) { + const { data: profiles } = await adminData + .from('profiles') + .select('id, name, email') + .in('id', userIds); + setProfilesById((profiles || []).reduce((acc: Record, profile) => { + acc[profile.id] = profile; + return acc; + }, {})); + } else { + setProfilesById({}); + } + } catch (error) { + console.error('Failed to load sent dares:', error); + toast.error('Failed to load sent dares'); + } finally { + setLoading(false); + } + }, [page, pageSize, statusFilter]); + + useEffect(() => { fetchDares(); }, [fetchDares]); + + useEffect(() => { setPage(1); }, [statusFilter]); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + if (page > totalPages) setPage(totalPages); + }, [page, pageSize, total]); + + const renderUserLink = (userId: string) => { + const profile = profilesById[userId]; + return ( + + {profile?.name || profile?.email || 'Unknown user'} + + ); + }; + + const openDetail = async (dare: SentDare) => { + setSelectedDare(dare); + setMessagesLoading(true); + setMessages([]); + try { + const { data, error } = await adminData + .from('dare_messages') + .select('id, sender_id, content, created_at, read_at') + .eq('sent_dare_id', dare.id) + .order('created_at', { ascending: true }); + if (error) throw error; + setMessages(data || []); + } catch (error) { + console.error('Failed to load dare messages:', error); + toast.error('Failed to load dare messages'); + } finally { + setMessagesLoading(false); + } + }; + + return ( +
+
+
+

+ + Sent Dares +

+

+ Review the dares loop between couples for moderation and support +

+
+ +
+ + + + + + Dare + Sender + Recipient + Status + Sent + Expires + Actions + + + + {loading ? ( + + +
+
+
+ ) : dares.length === 0 ? ( + + + No sent dares found. + + + ) : ( + dares.map((dare) => { + const statusInfo = SENT_DARE_STATUS_LABELS[dare.status as SentDareStatus]; + const overdue = isSentDareOverdue(dare.status, dare.expires_at); + return ( + + + {dare.custom_dare_text || dare.dare_text_snapshot} + + {renderUserLink(dare.sender_id)} + {renderUserLink(dare.recipient_id)} + +
+ {statusInfo?.label ?? dare.status} + {overdue && Overdue} +
+
+ + {format(new Date(dare.sent_at), 'MMM d, yyyy HH:mm')} + + + {dare.expires_at ? format(new Date(dare.expires_at), 'MMM d, yyyy HH:mm') : 'No expiry'} + + + + +
+ ); + }) + )} +
+
+
+ + {total > 0 && ( + { setPage(1); setPageSize(size); }} + /> + )} + + !open && setSelectedDare(null)}> + + + + + Dare Messages + + + {selectedDare && (selectedDare.custom_dare_text || selectedDare.dare_text_snapshot)} + + +
+ {messagesLoading ? ( + + ) : messages.length === 0 ? ( +

No messages for this dare

+ ) : ( + messages.map((message) => ( +
+
+ + {selectedDare && (message.sender_id === selectedDare.sender_id ? 'Sender' : 'Recipient')} + + + {format(new Date(message.created_at), 'MMM d, yyyy HH:mm')} + +
+

{message.content}

+
+ )) + )} +
+
+
+
+ ); +} diff --git a/apps/admin/src/pages/users/CouplesPage.tsx b/apps/admin/src/pages/users/CouplesPage.tsx new file mode 100644 index 0000000..c005fe1 --- /dev/null +++ b/apps/admin/src/pages/users/CouplesPage.tsx @@ -0,0 +1,200 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { adminData } from '@/lib/adminApi'; +import { Badge } from '@/components/ui/badge'; +import { Card } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Skeleton } from '@/components/ui/skeleton'; +import { PaginationControls } from '@/components/ui/pagination'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Heart, Search, Users, Hourglass } from 'lucide-react'; +import { format } from 'date-fns'; +import { toast } from 'sonner'; +import { buildCoupleSearchFilter, coupleStatusFromMemberCount } from '@/lib/couples'; + +interface Couple { + id: string; + invite_code: string; + created_at: string; +} + +interface Profile { + id: string; + name: string | null; + email: string | null; + couple_id: string | null; +} + +export function CouplesPage() { + const [couples, setCouples] = useState([]); + const [membersByCouple, setMembersByCouple] = useState>({}); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const [total, setTotal] = useState(0); + + const fetchCouples = useCallback(async () => { + setLoading(true); + try { + const from = (page - 1) * pageSize; + const to = from + pageSize - 1; + + let query = adminData + .from('couples') + .select('id, invite_code, created_at', { count: 'exact' }) + .order('created_at', { ascending: false }) + .range(from, to); + + const filter = buildCoupleSearchFilter(search); + if (filter) { + query = filter.op === 'eq' ? query.eq(filter.column, filter.value) : query.ilike(filter.column, filter.value); + } + + const { data, error, count } = await query; + if (error) throw error; + + const rows = data || []; + setCouples(rows); + setTotal(count || 0); + + const coupleIds = rows.map((row) => row.id); + if (coupleIds.length > 0) { + const { data: profiles } = await adminData + .from('profiles') + .select('id, name, email, couple_id') + .in('couple_id', coupleIds); + const grouped: Record = {}; + (profiles || []).forEach((profile) => { + if (!profile.couple_id) return; + (grouped[profile.couple_id] ??= []).push(profile); + }); + setMembersByCouple(grouped); + } else { + setMembersByCouple({}); + } + } catch (error) { + console.error('Failed to load couples:', error); + toast.error('Failed to load couples'); + } finally { + setLoading(false); + } + }, [page, pageSize, search]); + + useEffect(() => { fetchCouples(); }, [fetchCouples]); + useEffect(() => { setPage(1); }, [search]); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + if (page > totalPages) setPage(totalPages); + }, [page, pageSize, total]); + + return ( +
+
+
+

+ + Couples +

+

+ Look up an invite funnel by invite code or couple id +

+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+
+ + + + + + Invite Code + Members + Status + Created + + + + {loading ? ( + + +
+
+
+ ) : couples.length === 0 ? ( + + + {search ? 'No couples found matching your search.' : 'No couples yet.'} + + + ) : ( + couples.map((couple) => { + const members = membersByCouple[couple.id] || []; + const status = coupleStatusFromMemberCount(members.length); + return ( + + {couple.invite_code} + + {members.length === 0 ? ( + No members + ) : ( +
+ {members.map((member) => ( + + {member.name || member.email || member.id} + + ))} +
+ )} +
+ + {status === 'paired' ? ( + + + Paired + + ) : ( + + + Pending + + )} + + + {format(new Date(couple.created_at), 'MMM d, yyyy')} + +
+ ); + }) + )} +
+
+
+ + {total > 0 && ( + { setPage(1); setPageSize(size); }} + /> + )} +
+ ); +} diff --git a/apps/admin/src/pages/users/UserDetailPage.tsx b/apps/admin/src/pages/users/UserDetailPage.tsx index ef24c0d..3a48d1d 100644 --- a/apps/admin/src/pages/users/UserDetailPage.tsx +++ b/apps/admin/src/pages/users/UserDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useParams, Link } from 'react-router-dom'; import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; @@ -37,12 +37,14 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Crown, Users, MessageCircle, ChevronRight, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock, Pencil } from 'lucide-react'; +import { Crown, Users, MessageCircle, ChevronRight, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock, Pencil, CreditCard, Bell, Globe, BadgeCheck, HelpCircle, Swords } from 'lucide-react'; import { format } from 'date-fns'; import { IconPreview } from '@/components/ui/icon-picker'; import type { AnswerType, QuestionType } from '@sauci/shared'; import { ResponseAnswer } from '@/components/content/ResponseAnswer'; import { MATCH_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses'; +import { StrokeCanvas, type StrokeSegment } from '@/components/content/StrokeCanvas'; +import { SENT_DARE_STATUS_LABELS, type SentDareStatus } from '@/lib/sentDares'; type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship'; type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say'; @@ -61,6 +63,7 @@ interface Profile { usage_reason: UsageReason | null; hide_nsfw: boolean | null; onboarding_completed: boolean | null; + timezone: string | null; } const usageReasonLabels: Record = { @@ -158,11 +161,72 @@ interface CoupleStreak { current_streak: number; longest_streak: number; last_active_date: string | null; + last_completed_date: string | null; last_milestone_celebrated: number | null; created_at: string; updated_at: string; } +interface NotificationPreferences { + matches_enabled: boolean; + messages_enabled: boolean; + partner_activity_enabled: boolean; + nudges_enabled: boolean; + pack_changes_enabled: boolean; + new_packs_enabled: boolean; + streak_milestones_enabled: boolean; + streak_reminders_enabled: boolean; + weekly_summary_enabled: boolean; + unpaired_reminders_enabled: boolean; + catchup_reminders_enabled: boolean; + dares_enabled: boolean; +} + +const notificationPreferenceLabels: Record = { + matches_enabled: 'Matches', + messages_enabled: 'Messages', + partner_activity_enabled: 'Partner activity', + nudges_enabled: 'Nudges', + pack_changes_enabled: 'Pack changes', + new_packs_enabled: 'New packs', + streak_milestones_enabled: 'Streak milestones', + streak_reminders_enabled: 'Streak reminders', + weekly_summary_enabled: 'Weekly summary', + unpaired_reminders_enabled: 'Unpaired reminders', + catchup_reminders_enabled: 'Catch-up reminders', + dares_enabled: 'Dares', +}; + +interface Subscription { + id: string; + store: string; + product_id: string; + status: string; + entitlement_ids: string[]; + purchased_at: string; + expires_at: string | null; + revenuecat_app_user_id: string; +} + +interface QuizSession { + id: string; + status: string; + score_percent: number | null; + completed_at: string | null; + created_at: string; +} + +interface SentDare { + id: string; + dare_text_snapshot: string; + custom_dare_text: string | null; + sender_id: string; + recipient_id: string; + status: string; + sent_at: string; + expires_at: string | null; +} + const matchTypeLabels = { yes_yes: 'Both Yes!', yes_maybe: 'Yes + Maybe', @@ -269,25 +333,7 @@ function AdminDecryptedVideo({ messageId }: { messageId: string }) { return