mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
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.
This commit is contained in:
1 parent
eab73e6314
commit
8e83cc0d85
11 files changed
+1261
-77
No files matched your search
@@ -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() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/couples"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<CouplesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/activity"
|
||||
element={
|
||||
@@ -159,6 +170,22 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/activity/sent-dares"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_ACTIVITY}>
|
||||
<SentDaresPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/activity/live-draw"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<LiveDrawSessionsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* System */}
|
||||
<Route
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export interface StrokePoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface StrokeSegment {
|
||||
id: string;
|
||||
userId: string;
|
||||
points: StrokePoint[];
|
||||
color: string;
|
||||
width: number;
|
||||
timestamp: number;
|
||||
isEraser: boolean;
|
||||
}
|
||||
|
||||
export const STROKE_CANVAS_BACKGROUND = '#1a1a2e';
|
||||
|
||||
/**
|
||||
* Renders a `live_draw_sessions.strokes` array to a canvas. Points are stored
|
||||
* as 0..1 fractions of the drawing surface, so this scales to whatever size
|
||||
* the canvas element is laid out at.
|
||||
*/
|
||||
export function StrokeCanvas({ strokes }: { strokes: StrokeSegment[] }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(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 (
|
||||
<div className="rounded-xl overflow-hidden border" style={{ background: STROKE_CANVAS_BACKGROUND }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: '100%', aspectRatio: '4/3', display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: <Activity className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.VIEW_ACTIVITY,
|
||||
},
|
||||
{
|
||||
label: 'Sent Dares',
|
||||
href: '/activity/sent-dares',
|
||||
icon: <Swords className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.VIEW_ACTIVITY,
|
||||
},
|
||||
{
|
||||
label: 'Live Draw Sessions',
|
||||
href: '/activity/live-draw',
|
||||
icon: <Pencil className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.VIEW_USERS,
|
||||
},
|
||||
{
|
||||
label: 'Couples',
|
||||
href: '/couples',
|
||||
icon: <Heart className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.VIEW_USERS,
|
||||
},
|
||||
{
|
||||
label: 'Usage Insights',
|
||||
href: '/usage-insights',
|
||||
|
||||
@@ -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%',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}%` };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<SentDareStatus, { label: string; variant: BadgeVariant }> = {
|
||||
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<SentDareStatus> = 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();
|
||||
}
|
||||
@@ -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<LiveDrawSession[]>([]);
|
||||
const [membersByCouple, setMembersByCouple] = useState<Record<string, Profile[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const [selectedCoupleId, setSelectedCoupleId] = useState<string | null>(null);
|
||||
const [strokes, setStrokes] = useState<StrokeSegment[]>([]);
|
||||
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<LiveDrawSession>('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<Profile>('profiles')
|
||||
.select('id, name, email, couple_id')
|
||||
.in('couple_id', coupleIds);
|
||||
const grouped: Record<string, Profile[]> = {};
|
||||
(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 <span className="text-muted-foreground text-sm">Unknown couple</span>;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{members.map((member) => (
|
||||
<Link key={member.id} to={`/users/${member.id}`} className="text-primary hover:underline text-sm">
|
||||
{member.name || member.email || member.id}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Pencil className="h-8 w-8 text-primary" />
|
||||
Live Draw Sessions
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Recent live-draw activity across couples, most recently updated first
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Couple</TableHead>
|
||||
<TableHead>Revision</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Last Updated</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8">
|
||||
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : sessions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
No live draw sessions yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<TableRow key={session.couple_id}>
|
||||
<TableCell>{renderMembers(session.couple_id)}</TableCell>
|
||||
<TableCell><Badge variant="outline">{session.revision}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{format(new Date(session.created_at), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{format(new Date(session.updated_at), 'MMM d, yyyy HH:mm')}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => previewSession(session.couple_id)}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Preview
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{total > 0 && (
|
||||
<PaginationControls
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedCoupleId && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">Drawing Preview</CardTitle>
|
||||
<CardDescription>Read-only snapshot of the couple's shared canvas</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{strokesLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : strokes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Pencil className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No strokes to show</p>
|
||||
</div>
|
||||
) : (
|
||||
<StrokeCanvas strokes={strokes} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<SentDare[]>([]);
|
||||
const [profilesById, setProfilesById] = useState<Record<string, Profile>>({});
|
||||
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<SentDare | null>(null);
|
||||
const [messages, setMessages] = useState<DareMessage[]>([]);
|
||||
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<SentDare>('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<Profile>('profiles')
|
||||
.select('id, name, email')
|
||||
.in('id', userIds);
|
||||
setProfilesById((profiles || []).reduce((acc: Record<string, Profile>, 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 (
|
||||
<Link to={`/users/${userId}`} className="text-primary hover:underline">
|
||||
{profile?.name || profile?.email || 'Unknown user'}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const openDetail = async (dare: SentDare) => {
|
||||
setSelectedDare(dare);
|
||||
setMessagesLoading(true);
|
||||
setMessages([]);
|
||||
try {
|
||||
const { data, error } = await adminData
|
||||
.from<DareMessage>('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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Swords className="h-8 w-8 text-primary" />
|
||||
Sent Dares
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Review the dares loop between couples for moderation and support
|
||||
</p>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={(value) => setStatusFilter(value as 'all' | SentDareStatus)}>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_FILTER_OPTIONS.map((status) => (
|
||||
<SelectItem key={status} value={status} className="capitalize">
|
||||
{status === 'all' ? 'All statuses' : SENT_DARE_STATUS_LABELS[status].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Dare</TableHead>
|
||||
<TableHead>Sender</TableHead>
|
||||
<TableHead>Recipient</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Sent</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8">
|
||||
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : dares.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
No sent dares found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
dares.map((dare) => {
|
||||
const statusInfo = SENT_DARE_STATUS_LABELS[dare.status as SentDareStatus];
|
||||
const overdue = isSentDareOverdue(dare.status, dare.expires_at);
|
||||
return (
|
||||
<TableRow key={dare.id}>
|
||||
<TableCell className="max-w-[280px]">
|
||||
<span className="line-clamp-2 block">{dare.custom_dare_text || dare.dare_text_snapshot}</span>
|
||||
</TableCell>
|
||||
<TableCell>{renderUserLink(dare.sender_id)}</TableCell>
|
||||
<TableCell>{renderUserLink(dare.recipient_id)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={statusInfo?.variant ?? 'secondary'} className="capitalize">{statusInfo?.label ?? dare.status}</Badge>
|
||||
{overdue && <Badge variant="destructive">Overdue</Badge>}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{format(new Date(dare.sent_at), 'MMM d, yyyy HH:mm')}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{dare.expires_at ? format(new Date(dare.expires_at), 'MMM d, yyyy HH:mm') : 'No expiry'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => openDetail(dare)}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Messages
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{total > 0 && (
|
||||
<PaginationControls
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={!!selectedDare} onOpenChange={(open) => !open && setSelectedDare(null)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Dare Messages
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedDare && (selectedDare.custom_dare_text || selectedDare.dare_text_snapshot)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-[120px] max-h-[400px] overflow-y-auto space-y-3 bg-muted/30 rounded-md p-4">
|
||||
{messagesLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : messages.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">No messages for this dare</p>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div key={message.id} className="bg-background border rounded p-3 text-sm">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-medium">
|
||||
{selectedDare && (message.sender_id === selectedDare.sender_id ? 'Sender' : 'Recipient')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{format(new Date(message.created_at), 'MMM d, yyyy HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Couple[]>([]);
|
||||
const [membersByCouple, setMembersByCouple] = useState<Record<string, Profile[]>>({});
|
||||
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<Couple>('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<Profile>('profiles')
|
||||
.select('id, name, email, couple_id')
|
||||
.in('couple_id', coupleIds);
|
||||
const grouped: Record<string, Profile[]> = {};
|
||||
(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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Heart className="h-8 w-8 text-primary" />
|
||||
Couples
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Look up an invite funnel by invite code or couple id
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-full sm:w-72">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search invite code or couple id..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Invite Code</TableHead>
|
||||
<TableHead>Members</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8">
|
||||
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : couples.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||
{search ? 'No couples found matching your search.' : 'No couples yet.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
couples.map((couple) => {
|
||||
const members = membersByCouple[couple.id] || [];
|
||||
const status = coupleStatusFromMemberCount(members.length);
|
||||
return (
|
||||
<TableRow key={couple.id}>
|
||||
<TableCell className="font-mono">{couple.invite_code}</TableCell>
|
||||
<TableCell>
|
||||
{members.length === 0 ? (
|
||||
<span className="text-muted-foreground text-sm">No members</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{members.map((member) => (
|
||||
<Link key={member.id} to={`/users/${member.id}`} className="text-primary hover:underline text-sm">
|
||||
{member.name || member.email || member.id}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{status === 'paired' ? (
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-700 hover:bg-green-200">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
Paired
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-amber-100 text-amber-700 hover:bg-amber-200">
|
||||
<Hourglass className="h-3 w-3 mr-1" />
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{format(new Date(couple.created_at), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{total > 0 && (
|
||||
<PaginationControls
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<UsageReason, { label: string; icon: string }> = {
|
||||
@@ -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<keyof NotificationPreferences, string> = {
|
||||
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 <video src={url} controls className="w-full h-full object-cover" preload="metadata" />;
|
||||
}
|
||||
|
||||
interface StrokePoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface StrokeSegment {
|
||||
id: string;
|
||||
userId: string;
|
||||
points: StrokePoint[];
|
||||
color: string;
|
||||
width: number;
|
||||
timestamp: number;
|
||||
isEraser: boolean;
|
||||
}
|
||||
|
||||
const CANVAS_BACKGROUND = '#1a1a2e';
|
||||
|
||||
function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [strokes, setStrokes] = useState<StrokeSegment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -322,56 +368,6 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
||||
};
|
||||
}, [coupleId]);
|
||||
|
||||
// Render strokes to canvas
|
||||
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 = CANVAS_BACKGROUND;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
for (const stroke of strokes) {
|
||||
if (stroke.points.length === 0) continue;
|
||||
ctx.strokeStyle = stroke.isEraser ? 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]);
|
||||
|
||||
if (loading) return <Skeleton className="h-64 w-full" />;
|
||||
if (error) return <div className="text-sm text-red-500">Failed to load drawing: {error}</div>;
|
||||
|
||||
@@ -384,13 +380,8 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl overflow-hidden border" style={{ background: CANVAS_BACKGROUND }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: '100%', aspectRatio: '4/3', display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{strokes.length} stroke{strokes.length !== 1 ? 's' : ''} — updates in real-time</p>
|
||||
<StrokeCanvas strokes={strokes} />
|
||||
<p className="text-xs text-muted-foreground">{strokes.length} stroke{strokes.length !== 1 ? 's' : ''} - updates in real-time</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -424,6 +415,10 @@ export function UserDetailPage() {
|
||||
const [mediaPage, setMediaPage] = useState(1);
|
||||
const [mediaPageSize, setMediaPageSize] = useState(12);
|
||||
const [mediaTotal, setMediaTotal] = useState(0);
|
||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
|
||||
const [notificationPrefs, setNotificationPrefs] = useState<NotificationPreferences | null>(null);
|
||||
const [quizSessions, setQuizSessions] = useState<QuizSession[]>([]);
|
||||
const [sentDares, setSentDares] = useState<SentDare[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
|
||||
@@ -492,6 +487,39 @@ export function UserDetailPage() {
|
||||
.order('created_at', { ascending: false });
|
||||
setFeatureInterests((featureInterestsData || []) as FeatureInterest[]);
|
||||
|
||||
// Fetch purchase history so support can see what was bought and when it lapses,
|
||||
// instead of only the profile's is_premium boolean.
|
||||
const { data: subscriptionsData } = await adminData
|
||||
.from('subscriptions')
|
||||
.select('id, store, product_id, status, entitlement_ids, purchased_at, expires_at, revenuecat_app_user_id')
|
||||
.eq('user_id', userId)
|
||||
.order('purchased_at', { ascending: false });
|
||||
setSubscriptions((subscriptionsData || []) as Subscription[]);
|
||||
|
||||
// Fetch notification preferences (read-only visibility for support).
|
||||
const { data: notificationPrefsData } = await adminData
|
||||
.from('notification_preferences')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
setNotificationPrefs(notificationPrefsData);
|
||||
|
||||
// Fetch recent dares sent by or to this user (sent_dares has no OR support
|
||||
// in the admin query builder, so this issues two filtered queries and merges).
|
||||
const [sentByUser, sentToUser] = await Promise.all([
|
||||
adminData.from('sent_dares')
|
||||
.select('id, dare_text_snapshot, custom_dare_text, sender_id, recipient_id, status, sent_at, expires_at')
|
||||
.eq('sender_id', userId).order('sent_at', { ascending: false }).limit(10),
|
||||
adminData.from('sent_dares')
|
||||
.select('id, dare_text_snapshot, custom_dare_text, sender_id, recipient_id, status, sent_at, expires_at')
|
||||
.eq('recipient_id', userId).order('sent_at', { ascending: false }).limit(10),
|
||||
]);
|
||||
const daresById = new Map<string, SentDare>();
|
||||
for (const row of [...(sentByUser.data || []), ...(sentToUser.data || [])] as SentDare[]) {
|
||||
daresById.set(row.id, row);
|
||||
}
|
||||
setSentDares([...daresById.values()].sort((a, b) => new Date(b.sent_at).getTime() - new Date(a.sent_at).getTime()).slice(0, 10));
|
||||
|
||||
if (profileData?.couple_id) {
|
||||
const { data: partnerData } = await adminData.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
||||
setPartner(partnerData);
|
||||
@@ -503,9 +531,20 @@ export function UserDetailPage() {
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
.maybeSingle();
|
||||
setStreak(streakData);
|
||||
|
||||
// Fetch recent quiz sessions for the couple (quiz_sessions has no user_id
|
||||
// column; sessions belong to the couple both partners share).
|
||||
const { data: quizSessionsData } = await adminData
|
||||
.from('quiz_sessions')
|
||||
.select('id, status, score_percent, completed_at, created_at')
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
setQuizSessions((quizSessionsData || []) as QuizSession[]);
|
||||
} else {
|
||||
setPartner(null);
|
||||
setStreak(null);
|
||||
setQuizSessions([]);
|
||||
}
|
||||
|
||||
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
||||
@@ -789,12 +828,59 @@ export function UserDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div></CardContent></Card>
|
||||
{/* Subscriptions */}
|
||||
{subscriptions.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
Subscriptions
|
||||
</CardTitle>
|
||||
<CardDescription>Purchase history from the store, independent of the is_premium flag</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Store</TableHead>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Entitlements</TableHead>
|
||||
<TableHead>Purchased</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>RevenueCat User</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subscriptions.map((subscription) => (
|
||||
<TableRow key={subscription.id}>
|
||||
<TableCell className="capitalize">{subscription.store.toLowerCase().replace('_', ' ')}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{subscription.product_id}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={subscription.status === 'active' ? 'default' : subscription.status === 'billing_issue' ? 'destructive' : 'secondary'} className="capitalize">
|
||||
{subscription.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{subscription.entitlement_ids.join(', ') || '-'}</TableCell>
|
||||
<TableCell className="text-sm">{format(new Date(subscription.purchased_at), 'MMM d, yyyy')}</TableCell>
|
||||
<TableCell className="text-sm">{subscription.expires_at ? format(new Date(subscription.expires_at), 'MMM d, yyyy') : 'Never'}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">{subscription.revenuecat_app_user_id}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Onboarding Details */}
|
||||
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
|
||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
|
||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.hide_nsfw ? 'bg-gray-100 dark:bg-gray-800' : 'bg-amber-100 dark:bg-amber-900'}`}>{profile.hide_nsfw ? <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" /> : <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Hide NSFW</p><p className="font-medium">{profile.hide_nsfw === null ? 'Not set' : profile.hide_nsfw ? 'Yes' : 'No'}</p></div></div>
|
||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
|
||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-teal-100 dark:bg-teal-900"><Globe className="h-4 w-4 text-teal-600 dark:text-teal-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Timezone</p><p className="font-medium">{profile.timezone || 'Not reported (UTC fallback)'}</p></div></div>
|
||||
</div></CardContent></Card>
|
||||
{/* Streak Info */}
|
||||
{profile.couple_id && streak && (
|
||||
@@ -835,6 +921,16 @@ export function UserDetailPage() {
|
||||
<p className="font-medium">{streak.last_active_date ? format(new Date(streak.last_active_date), 'MMM d, yyyy') : 'Never'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-900">
|
||||
<BadgeCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Last Completed (both partners)</p>
|
||||
<p className="font-medium">{streak.last_completed_date ? format(new Date(streak.last_completed_date), 'MMM d, yyyy') : 'Never'}</p>
|
||||
<p className="text-xs text-muted-foreground">Determines whether the streak is alive today or tomorrow</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900">
|
||||
<Sparkles className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
@@ -848,6 +944,28 @@ export function UserDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Notification Preferences */}
|
||||
{notificationPrefs && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
Notification Preferences
|
||||
</CardTitle>
|
||||
<CardDescription>Read-only. Configured by the user in the mobile app</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(notificationPreferenceLabels) as Array<keyof NotificationPreferences>).map((key) => (
|
||||
<Badge key={key} variant={notificationPrefs[key] ? 'default' : 'secondary'} className="gap-1">
|
||||
{notificationPrefs[key] ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
|
||||
{notificationPreferenceLabels[key]}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Feature Opt-Ins */}
|
||||
{featureInterests.length > 0 && (
|
||||
<Card>
|
||||
@@ -872,6 +990,83 @@ export function UserDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Quiz Sessions */}
|
||||
{quizSessions.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<HelpCircle className="h-5 w-5 text-primary" />
|
||||
Couples Quiz Sessions
|
||||
</CardTitle>
|
||||
<CardDescription>Recent quiz sessions for this couple</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Completed</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{quizSessions.map((session) => (
|
||||
<TableRow key={session.id}>
|
||||
<TableCell>
|
||||
<Badge variant={session.status === 'completed' ? 'default' : 'secondary'} className="capitalize">{session.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{session.score_percent !== null ? `${session.score_percent}%` : '-'}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{format(new Date(session.created_at), 'MMM d, yyyy')}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{session.completed_at ? format(new Date(session.completed_at), 'MMM d, yyyy') : '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Recent Dares */}
|
||||
{sentDares.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Swords className="h-5 w-5 text-primary" />
|
||||
Recent Dares
|
||||
</CardTitle>
|
||||
<CardDescription>Dares this user sent or received. See the Sent Dares page for the full loop and messages</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Dare</TableHead>
|
||||
<TableHead>Direction</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Sent</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sentDares.map((dare) => {
|
||||
const statusInfo = SENT_DARE_STATUS_LABELS[dare.status as SentDareStatus];
|
||||
return (
|
||||
<TableRow key={dare.id}>
|
||||
<TableCell className="max-w-md line-clamp-2">{dare.custom_dare_text || dare.dare_text_snapshot}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{dare.sender_id === userId ? 'Sent' : 'Received'}</TableCell>
|
||||
<TableCell><Badge variant={statusInfo?.variant ?? 'secondary'} className="capitalize">{statusInfo?.label ?? dare.status}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{format(new Date(dare.sent_at), 'MMM d, yyyy')}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
||||
<TabsList>
|
||||
|
||||
Reference in new issue
Block a user