Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc515a19b0 | ||
|
|
b933c3c421 | ||
|
|
aff0bc0888 | ||
|
|
b60b72a289 |
No files matched your search
@@ -34,7 +34,6 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-icons": "^4.12.0",
|
"react-icons": "^4.12.0",
|
||||||
"react-router-dom": "^6.21.0",
|
"react-router-dom": "^6.21.0",
|
||||||
|
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"sonner": "^1.3.1",
|
"sonner": "^1.3.1",
|
||||||
"tailwind-merge": "^2.2.0"
|
"tailwind-merge": "^2.2.0"
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { DashboardPage } from '@/pages/DashboardPage';
|
|||||||
import { CategoriesPage } from '@/pages/content/CategoriesPage';
|
import { CategoriesPage } from '@/pages/content/CategoriesPage';
|
||||||
import { PacksPage } from '@/pages/content/PacksPage';
|
import { PacksPage } from '@/pages/content/PacksPage';
|
||||||
import { QuestionsPage } from '@/pages/content/QuestionsPage';
|
import { QuestionsPage } from '@/pages/content/QuestionsPage';
|
||||||
|
import { DarePacksPage } from '@/pages/content/DarePacksPage';
|
||||||
|
import { DaresPage } from '@/pages/content/DaresPage';
|
||||||
import { UsersPage } from '@/pages/users/UsersPage';
|
import { UsersPage } from '@/pages/users/UsersPage';
|
||||||
import { UserDetailPage } from '@/pages/users/UserDetailPage';
|
import { UserDetailPage } from '@/pages/users/UserDetailPage';
|
||||||
import { MatchChatPage } from '@/pages/users/MatchChatPage';
|
import { MatchChatPage } from '@/pages/users/MatchChatPage';
|
||||||
@@ -64,6 +66,22 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dare-packs"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_PACKS}>
|
||||||
|
<DarePacksPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dare-packs/:packId/dares"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
|
||||||
|
<DaresPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* User management */}
|
{/* User management */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Home,
|
Home,
|
||||||
Activity,
|
Activity,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
@@ -47,6 +48,12 @@ const contentNav: NavItem[] = [
|
|||||||
icon: <LayoutGrid className="h-5 w-5" />,
|
icon: <LayoutGrid className="h-5 w-5" />,
|
||||||
requiredPermission: PERMISSION_KEYS.MANAGE_CATEGORIES,
|
requiredPermission: PERMISSION_KEYS.MANAGE_CATEGORIES,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Dare Packs',
|
||||||
|
href: '/dare-packs',
|
||||||
|
icon: <Zap className="h-5 w-5" />,
|
||||||
|
requiredPermission: PERMISSION_KEYS.MANAGE_PACKS,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const usersNav: NavItem[] = [
|
const usersNav: NavItem[] = [
|
||||||
|
|||||||
@@ -1,48 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { IconType } from 'react-icons';
|
import type { IconType } from 'react-icons';
|
||||||
import {
|
import * as Io5Icons from 'react-icons/io5';
|
||||||
IoHeartOutline,
|
|
||||||
IoHeartHalfOutline,
|
|
||||||
IoHeartCircleOutline,
|
|
||||||
IoChatbubblesOutline,
|
|
||||||
IoChatboxOutline,
|
|
||||||
IoMailOutline,
|
|
||||||
IoFlowerOutline,
|
|
||||||
IoWineOutline,
|
|
||||||
IoRestaurantOutline,
|
|
||||||
IoCafeOutline,
|
|
||||||
IoAirplaneOutline,
|
|
||||||
IoCarOutline,
|
|
||||||
IoCompassOutline,
|
|
||||||
IoMapOutline,
|
|
||||||
IoHomeOutline,
|
|
||||||
IoPeopleOutline,
|
|
||||||
IoPersonOutline,
|
|
||||||
IoEyeOffOutline,
|
|
||||||
IoKeyOutline,
|
|
||||||
IoLockClosedOutline,
|
|
||||||
IoDiceOutline,
|
|
||||||
IoGiftOutline,
|
|
||||||
IoSparklesOutline,
|
|
||||||
IoStarOutline,
|
|
||||||
IoFlameOutline,
|
|
||||||
IoFlashOutline,
|
|
||||||
IoMoonOutline,
|
|
||||||
IoSunnyOutline,
|
|
||||||
IoFlagOutline,
|
|
||||||
IoCalendarOutline,
|
|
||||||
IoCheckboxOutline,
|
|
||||||
IoTrophyOutline,
|
|
||||||
IoLayersOutline,
|
|
||||||
IoCubeOutline,
|
|
||||||
IoFolderOutline,
|
|
||||||
IoBookmarkOutline,
|
|
||||||
IoBulbOutline,
|
|
||||||
IoColorWandOutline,
|
|
||||||
IoSyncOutline,
|
|
||||||
IoRefreshOutline,
|
|
||||||
IoBriefcaseOutline,
|
|
||||||
} from 'react-icons/io5';
|
|
||||||
import { Button } from './button';
|
import { Button } from './button';
|
||||||
import { Input } from './input';
|
import { Input } from './input';
|
||||||
|
|
||||||
@@ -51,68 +9,28 @@ interface IconOption {
|
|||||||
Icon: IconType;
|
Icon: IconType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICON_OPTIONS: IconOption[] = [
|
/**
|
||||||
// Relationships & Love
|
* Convert PascalCase component name to kebab-case Ionicon name.
|
||||||
{ name: 'heart-outline', Icon: IoHeartOutline },
|
* e.g., "IoHeartOutline" -> "heart-outline"
|
||||||
{ name: 'heart-half-outline', Icon: IoHeartHalfOutline },
|
*/
|
||||||
{ name: 'heart-circle-outline', Icon: IoHeartCircleOutline },
|
function toKebabCase(componentName: string): string {
|
||||||
|
// Remove "Io" prefix
|
||||||
|
const withoutPrefix = componentName.slice(2);
|
||||||
|
// Convert PascalCase to kebab-case
|
||||||
|
return withoutPrefix
|
||||||
|
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
||||||
|
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
// Communication
|
// Build icon options from all Io5 icons dynamically
|
||||||
{ name: 'chatbubbles-outline', Icon: IoChatbubblesOutline },
|
const ICON_OPTIONS: IconOption[] = Object.entries(Io5Icons)
|
||||||
{ name: 'chatbox-outline', Icon: IoChatboxOutline },
|
.filter(([name]) => name.startsWith('Io'))
|
||||||
{ name: 'mail-outline', Icon: IoMailOutline },
|
.map(([componentName, Icon]) => ({
|
||||||
|
name: toKebabCase(componentName),
|
||||||
// Romance & Dates
|
Icon: Icon as IconType,
|
||||||
{ name: 'flower-outline', Icon: IoFlowerOutline },
|
}))
|
||||||
{ name: 'wine-outline', Icon: IoWineOutline },
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
{ name: 'restaurant-outline', Icon: IoRestaurantOutline },
|
|
||||||
{ name: 'cafe-outline', Icon: IoCafeOutline },
|
|
||||||
|
|
||||||
// Adventure & Travel
|
|
||||||
{ name: 'airplane-outline', Icon: IoAirplaneOutline },
|
|
||||||
{ name: 'car-outline', Icon: IoCarOutline },
|
|
||||||
{ name: 'compass-outline', Icon: IoCompassOutline },
|
|
||||||
{ name: 'map-outline', Icon: IoMapOutline },
|
|
||||||
|
|
||||||
// Home & Family
|
|
||||||
{ name: 'home-outline', Icon: IoHomeOutline },
|
|
||||||
{ name: 'people-outline', Icon: IoPeopleOutline },
|
|
||||||
{ name: 'person-outline', Icon: IoPersonOutline },
|
|
||||||
|
|
||||||
// Mystery & Secrets
|
|
||||||
{ name: 'eye-off-outline', Icon: IoEyeOffOutline },
|
|
||||||
{ name: 'key-outline', Icon: IoKeyOutline },
|
|
||||||
{ name: 'lock-closed-outline', Icon: IoLockClosedOutline },
|
|
||||||
|
|
||||||
// Fun & Games
|
|
||||||
{ name: 'dice-outline', Icon: IoDiceOutline },
|
|
||||||
{ name: 'gift-outline', Icon: IoGiftOutline },
|
|
||||||
{ name: 'sparkles-outline', Icon: IoSparklesOutline },
|
|
||||||
{ name: 'star-outline', Icon: IoStarOutline },
|
|
||||||
|
|
||||||
// Intimacy
|
|
||||||
{ name: 'flame-outline', Icon: IoFlameOutline },
|
|
||||||
{ name: 'flash-outline', Icon: IoFlashOutline },
|
|
||||||
{ name: 'moon-outline', Icon: IoMoonOutline },
|
|
||||||
{ name: 'sunny-outline', Icon: IoSunnyOutline },
|
|
||||||
|
|
||||||
// Goals & Planning
|
|
||||||
{ name: 'flag-outline', Icon: IoFlagOutline },
|
|
||||||
{ name: 'calendar-outline', Icon: IoCalendarOutline },
|
|
||||||
{ name: 'checkbox-outline', Icon: IoCheckboxOutline },
|
|
||||||
{ name: 'trophy-outline', Icon: IoTrophyOutline },
|
|
||||||
|
|
||||||
// General
|
|
||||||
{ name: 'layers-outline', Icon: IoLayersOutline },
|
|
||||||
{ name: 'cube-outline', Icon: IoCubeOutline },
|
|
||||||
{ name: 'folder-outline', Icon: IoFolderOutline },
|
|
||||||
{ name: 'bookmark-outline', Icon: IoBookmarkOutline },
|
|
||||||
{ name: 'bulb-outline', Icon: IoBulbOutline },
|
|
||||||
{ name: 'color-wand-outline', Icon: IoColorWandOutline },
|
|
||||||
{ name: 'sync-outline', Icon: IoSyncOutline },
|
|
||||||
{ name: 'refresh-outline', Icon: IoRefreshOutline },
|
|
||||||
{ name: 'briefcase-outline', Icon: IoBriefcaseOutline },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ICONS_BY_NAME = ICON_OPTIONS.reduce<Record<string, IconOption>>((acc, option) => {
|
const ICONS_BY_NAME = ICON_OPTIONS.reduce<Record<string, IconOption>>((acc, option) => {
|
||||||
acc[option.name] = option;
|
acc[option.name] = option;
|
||||||
@@ -168,8 +86,9 @@ export function IconPicker({ value, onChange }: IconPickerProps) {
|
|||||||
return ICON_OPTIONS.filter((option) => option.name.includes(normalized));
|
return ICON_OPTIONS.filter((option) => option.name.includes(normalized));
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
const SelectedIcon = ICONS_BY_NAME[value]?.Icon;
|
const selectedOption = ICONS_BY_NAME[value];
|
||||||
const buttonLabel = SelectedIcon ? value : value ? 'Legacy icon' : 'Select an icon';
|
const SelectedIcon = selectedOption?.Icon;
|
||||||
|
const buttonLabel = selectedOption ? value : value ? 'Legacy icon' : 'Select an icon';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={containerRef}>
|
<div className="relative" ref={containerRef}>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type PaginationControlsProps = {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalCount: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
showPageSize?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PaginationControls({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalCount,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
pageSizeOptions = [10, 25, 50],
|
||||||
|
showPageSize = true,
|
||||||
|
className,
|
||||||
|
}: PaginationControlsProps) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
const safePage = Math.min(Math.max(page, 1), totalPages);
|
||||||
|
const start = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1;
|
||||||
|
const end = totalCount === 0 ? 0 : Math.min(safePage * pageSize, totalCount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-2 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{totalCount === 0
|
||||||
|
? 'No results'
|
||||||
|
: `Showing ${start} to ${end} of ${totalCount}`}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{showPageSize && onPageSizeChange && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs uppercase tracking-wide text-muted-foreground">Rows</span>
|
||||||
|
<Select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onValueChange={(value) => onPageSizeChange(Number(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[90px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{pageSizeOptions.map((size) => (
|
||||||
|
<SelectItem key={size} value={String(size)}>
|
||||||
|
{size}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(Math.max(1, safePage - 1))}
|
||||||
|
disabled={safePage <= 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||||
|
Prev
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Page {safePage} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(Math.min(totalPages, safePage + 1))}
|
||||||
|
disabled={safePage >= totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="ml-1 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -86,10 +86,10 @@ export const auditedSupabase = {
|
|||||||
/**
|
/**
|
||||||
* Update a single record with audit logging
|
* Update a single record with audit logging
|
||||||
*/
|
*/
|
||||||
async update<T extends { id: string }>(
|
async update<T extends object = Record<string, unknown>>(
|
||||||
table: string,
|
table: string,
|
||||||
id: string,
|
id: string,
|
||||||
updates: Partial<T>
|
updates: Partial<T> | Record<string, unknown>
|
||||||
): Promise<SingleResult<T>> {
|
): Promise<SingleResult<T>> {
|
||||||
// Fetch old values before update
|
// Fetch old values before update
|
||||||
let oldValues: Record<string, unknown> | null = null;
|
let oldValues: Record<string, unknown> | null = null;
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ import { useState, useCallback } from 'react';
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function useEntityForm<
|
export function useEntityForm<
|
||||||
TFormData extends Record<string, unknown>,
|
TFormData extends object,
|
||||||
TEntity extends Record<string, unknown> = TFormData
|
TEntity extends object = TFormData
|
||||||
>(
|
>(
|
||||||
initialFormData: TFormData,
|
initialFormData: TFormData,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { supabase } from '@/config';
|
import { supabase } from '@/config';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -34,28 +35,42 @@ const actionColors: Record<string, string> = {
|
|||||||
export function AuditLogsPage() {
|
export function AuditLogsPage() {
|
||||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(50);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchLogs = useCallback(async () => {
|
||||||
const fetchLogs = async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
|
const { data, error, count } = await supabase
|
||||||
.from('audit_logs')
|
.from('audit_logs')
|
||||||
.select('*')
|
.select('*', { count: 'exact' })
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(100);
|
.range(from, to);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setLogs(data || []);
|
setLogs(data || []);
|
||||||
|
setTotalCount(count || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load audit logs:', error);
|
console.error('Failed to load audit logs:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [page, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
fetchLogs();
|
fetchLogs();
|
||||||
}, []);
|
}, [fetchLogs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -141,6 +156,19 @@ export function AuditLogsPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||||
@@ -87,6 +89,9 @@ export function FeedbackPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
|
||||||
|
|
||||||
// Detail Dialog State
|
// Detail Dialog State
|
||||||
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
|
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
|
||||||
@@ -140,6 +145,11 @@ export function FeedbackPage() {
|
|||||||
fetchFeedback();
|
fetchFeedback();
|
||||||
}, [fetchFeedback]);
|
}, [fetchFeedback]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [search, typeFilter, statusFilter]);
|
||||||
|
|
||||||
|
|
||||||
const handleOpenDetail = (item: Feedback) => {
|
const handleOpenDetail = (item: Feedback) => {
|
||||||
setSelectedFeedback(item);
|
setSelectedFeedback(item);
|
||||||
setEditStatus(item.status);
|
setEditStatus(item.status);
|
||||||
@@ -187,6 +197,20 @@ export function FeedbackPage() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const totalCount = filteredFeedback.length;
|
||||||
|
const paginatedFeedback = filteredFeedback.slice(
|
||||||
|
(page - 1) * pageSize,
|
||||||
|
page * pageSize
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
|
|
||||||
const getTypeBadge = (type: FeedbackType) => {
|
const getTypeBadge = (type: FeedbackType) => {
|
||||||
const config = feedbackTypeConfig[type];
|
const config = feedbackTypeConfig[type];
|
||||||
return (
|
return (
|
||||||
@@ -295,7 +319,7 @@ export function FeedbackPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredFeedback.map((item) => (
|
paginatedFeedback.map((item) => (
|
||||||
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleOpenDetail(item)}>
|
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleOpenDetail(item)}>
|
||||||
<TableCell>{getTypeBadge(item.type)}</TableCell>
|
<TableCell>{getTypeBadge(item.type)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -329,6 +353,19 @@ export function FeedbackPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filteredFeedback.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Detail Dialog */}
|
{/* Detail Dialog */}
|
||||||
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
|
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
|
||||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { supabase } from '@/config';
|
import { supabase } from '@/config';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -65,10 +66,16 @@ export function FlaggedMessagesPage() {
|
|||||||
// AI Flagged state
|
// AI Flagged state
|
||||||
const [aiMessages, setAiMessages] = useState<FlaggedMessage[]>([]);
|
const [aiMessages, setAiMessages] = useState<FlaggedMessage[]>([]);
|
||||||
const [aiLoading, setAiLoading] = useState(true);
|
const [aiLoading, setAiLoading] = useState(true);
|
||||||
|
const [aiPage, setAiPage] = useState(1);
|
||||||
|
const [aiPageSize, setAiPageSize] = useState(25);
|
||||||
|
const [aiTotal, setAiTotal] = useState(0);
|
||||||
|
|
||||||
// User Reports state
|
// User Reports state
|
||||||
const [userReports, setUserReports] = useState<UserReport[]>([]);
|
const [userReports, setUserReports] = useState<UserReport[]>([]);
|
||||||
const [reportsLoading, setReportsLoading] = useState(true);
|
const [reportsLoading, setReportsLoading] = useState(true);
|
||||||
|
const [reportsPage, setReportsPage] = useState(1);
|
||||||
|
const [reportsPageSize, setReportsPageSize] = useState(25);
|
||||||
|
const [reportsTotal, setReportsTotal] = useState(0);
|
||||||
|
|
||||||
// Shared state
|
// Shared state
|
||||||
const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null);
|
const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null);
|
||||||
@@ -82,30 +89,38 @@ export function FlaggedMessagesPage() {
|
|||||||
const [describingInline, setDescribingInline] = useState<string | null>(null);
|
const [describingInline, setDescribingInline] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch AI flagged messages
|
// Fetch AI flagged messages
|
||||||
const fetchAiMessages = async () => {
|
const fetchAiMessages = useCallback(async () => {
|
||||||
setAiLoading(true);
|
setAiLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const from = (aiPage - 1) * aiPageSize;
|
||||||
|
const to = from + aiPageSize - 1;
|
||||||
|
|
||||||
|
const { data, error, count } = await supabase
|
||||||
.from('messages')
|
.from('messages')
|
||||||
.select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type')
|
.select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type', { count: 'exact' })
|
||||||
.eq('moderation_status', 'flagged')
|
.eq('moderation_status', 'flagged')
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setAiMessages(data || []);
|
setAiMessages(data || []);
|
||||||
|
setAiTotal(count || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load flagged messages:', error);
|
console.error('Failed to load flagged messages:', error);
|
||||||
toast.error('Failed to load messages');
|
toast.error('Failed to load messages');
|
||||||
} finally {
|
} finally {
|
||||||
setAiLoading(false);
|
setAiLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [aiPage, aiPageSize]);
|
||||||
|
|
||||||
// Fetch user reports
|
// Fetch user reports
|
||||||
const fetchUserReports = async () => {
|
const fetchUserReports = useCallback(async () => {
|
||||||
setReportsLoading(true);
|
setReportsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const from = (reportsPage - 1) * reportsPageSize;
|
||||||
|
const to = from + reportsPageSize - 1;
|
||||||
|
|
||||||
|
const { data, error, count } = await supabase
|
||||||
.from('message_reports')
|
.from('message_reports')
|
||||||
.select(`
|
.select(`
|
||||||
id,
|
id,
|
||||||
@@ -116,24 +131,40 @@ export function FlaggedMessagesPage() {
|
|||||||
created_at,
|
created_at,
|
||||||
message:messages(id, user_id, match_id, content, media_path, media_type),
|
message:messages(id, user_id, match_id, content, media_path, media_type),
|
||||||
reporter_profile:profiles!reporter_id(name, email)
|
reporter_profile:profiles!reporter_id(name, email)
|
||||||
`)
|
`, { count: 'exact' })
|
||||||
.eq('status', 'pending')
|
.eq('status', 'pending')
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setUserReports((data || []) as unknown as UserReport[]);
|
setUserReports((data || []) as unknown as UserReport[]);
|
||||||
|
setReportsTotal(count || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load user reports:', error);
|
console.error('Failed to load user reports:', error);
|
||||||
toast.error('Failed to load reports');
|
toast.error('Failed to load reports');
|
||||||
} finally {
|
} finally {
|
||||||
setReportsLoading(false);
|
setReportsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [reportsPage, reportsPageSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAiMessages();
|
fetchAiMessages();
|
||||||
fetchUserReports();
|
fetchUserReports();
|
||||||
}, []);
|
}, [fetchAiMessages, fetchUserReports]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(aiTotal / aiPageSize));
|
||||||
|
if (aiPage > totalPages) {
|
||||||
|
setAiPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [aiPage, aiPageSize, aiTotal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(reportsTotal / reportsPageSize));
|
||||||
|
if (reportsPage > totalPages) {
|
||||||
|
setReportsPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [reportsPage, reportsPageSize, reportsTotal]);
|
||||||
|
|
||||||
// Custom media fetcher because supabase.functions.invoke is JSON-centric
|
// Custom media fetcher because supabase.functions.invoke is JSON-centric
|
||||||
const fetchDecryptedMedia = async (messageId: string) => {
|
const fetchDecryptedMedia = async (messageId: string) => {
|
||||||
@@ -296,8 +327,8 @@ export function FlaggedMessagesPage() {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast.success('Message marked as safe');
|
toast.success('Message marked as safe');
|
||||||
setAiMessages(prev => prev.filter(m => m.id !== messageId));
|
|
||||||
if (selectedMessage?.id === messageId) setSelectedMessage(null);
|
if (selectedMessage?.id === messageId) setSelectedMessage(null);
|
||||||
|
fetchAiMessages();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to update status');
|
toast.error('Failed to update status');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -316,8 +347,8 @@ export function FlaggedMessagesPage() {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast.success('Report dismissed');
|
toast.success('Report dismissed');
|
||||||
setUserReports(prev => prev.filter(r => r.id !== reportId));
|
|
||||||
if (selectedReport?.id === reportId) setSelectedReport(null);
|
if (selectedReport?.id === reportId) setSelectedReport(null);
|
||||||
|
fetchUserReports();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to dismiss report');
|
toast.error('Failed to dismiss report');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -336,8 +367,8 @@ export function FlaggedMessagesPage() {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast.success('Report marked as reviewed');
|
toast.success('Report marked as reviewed');
|
||||||
setUserReports(prev => prev.filter(r => r.id !== reportId));
|
|
||||||
if (selectedReport?.id === reportId) setSelectedReport(null);
|
if (selectedReport?.id === reportId) setSelectedReport(null);
|
||||||
|
fetchUserReports();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to update report');
|
toast.error('Failed to update report');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -491,6 +522,19 @@ export function FlaggedMessagesPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{aiTotal > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={aiPage}
|
||||||
|
pageSize={aiPageSize}
|
||||||
|
totalCount={aiTotal}
|
||||||
|
onPageChange={setAiPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setAiPage(1);
|
||||||
|
setAiPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="user-reports" className="space-y-4">
|
<TabsContent value="user-reports" className="space-y-4">
|
||||||
@@ -579,6 +623,19 @@ export function FlaggedMessagesPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{reportsTotal > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={reportsPage}
|
||||||
|
pageSize={reportsPageSize}
|
||||||
|
totalCount={reportsTotal}
|
||||||
|
onPageChange={setReportsPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setReportsPage(1);
|
||||||
|
setReportsPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
import { supabase } from '@/config';
|
import { supabase } from '@/config';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -64,6 +67,10 @@ export function RedemptionCodesPage() {
|
|||||||
const [codes, setCodes] = useState<RedemptionCode[]>([]);
|
const [codes, setCodes] = useState<RedemptionCode[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
|
||||||
// Add Code State
|
// Add Code State
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
@@ -79,41 +86,60 @@ export function RedemptionCodesPage() {
|
|||||||
const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null);
|
const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null);
|
||||||
const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]);
|
const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]);
|
||||||
const [loadingRedemptions, setLoadingRedemptions] = useState(false);
|
const [loadingRedemptions, setLoadingRedemptions] = useState(false);
|
||||||
|
const [redemptionsPage, setRedemptionsPage] = useState(1);
|
||||||
|
const [redemptionsPageSize, setRedemptionsPageSize] = useState(10);
|
||||||
|
const [redemptionsTotal, setRedemptionsTotal] = useState(0);
|
||||||
|
|
||||||
// Initial load
|
|
||||||
useEffect(() => {
|
|
||||||
fetchCodes();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchCodes = async () => {
|
|
||||||
|
|
||||||
|
const fetchCodes = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
const trimmedSearch = search.trim();
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
.from('redemption_codes')
|
.from('redemption_codes')
|
||||||
.select('*')
|
.select('*', { count: 'exact' })
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (trimmedSearch) {
|
||||||
|
query = query.or(`code.ilike.%${trimmedSearch}%,description.ilike.%${trimmedSearch}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error, count } = await query.range(from, to);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setCodes(data || []);
|
setCodes(data || []);
|
||||||
|
setTotalCount(count || 0);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to load codes:', error);
|
console.error('Failed to load codes:', error);
|
||||||
toast.error("Failed to load redemption codes");
|
toast.error("Failed to load redemption codes");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [page, pageSize, search]);
|
||||||
|
|
||||||
const fetchRedemptions = async (codeId: string) => {
|
|
||||||
|
const fetchRedemptions = useCallback(async (codeId: string) => {
|
||||||
setLoadingRedemptions(true);
|
setLoadingRedemptions(true);
|
||||||
try {
|
try {
|
||||||
const { data: redemptionData, error: redemptionError } = await supabase
|
const from = (redemptionsPage - 1) * redemptionsPageSize;
|
||||||
|
const to = from + redemptionsPageSize - 1;
|
||||||
|
|
||||||
|
const { data: redemptionData, error: redemptionError, count } = await supabase
|
||||||
.from('code_redemptions')
|
.from('code_redemptions')
|
||||||
.select('id, user_id, redeemed_at')
|
.select('id, user_id, redeemed_at', { count: 'exact' })
|
||||||
.eq('code_id', codeId)
|
.eq('code_id', codeId)
|
||||||
.order('redeemed_at', { ascending: false });
|
.order('redeemed_at', { ascending: false })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
if (redemptionError) throw redemptionError;
|
if (redemptionError) throw redemptionError;
|
||||||
|
|
||||||
|
setRedemptionsTotal(count || 0);
|
||||||
|
|
||||||
if (redemptionData && redemptionData.length > 0) {
|
if (redemptionData && redemptionData.length > 0) {
|
||||||
const userIds = redemptionData.map(r => r.user_id);
|
const userIds = redemptionData.map(r => r.user_id);
|
||||||
const { data: profileData } = await supabase
|
const { data: profileData } = await supabase
|
||||||
@@ -135,7 +161,36 @@ export function RedemptionCodesPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingRedemptions(false);
|
setLoadingRedemptions(false);
|
||||||
}
|
}
|
||||||
};
|
}, [redemptionsPage, redemptionsPageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCodes();
|
||||||
|
}, [fetchCodes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewingCode) {
|
||||||
|
fetchRedemptions(viewingCode.id);
|
||||||
|
}
|
||||||
|
}, [viewingCode, fetchRedemptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(redemptionsTotal / redemptionsPageSize));
|
||||||
|
if (redemptionsPage > totalPages) {
|
||||||
|
setRedemptionsPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [redemptionsPage, redemptionsPageSize, redemptionsTotal]);
|
||||||
|
|
||||||
|
|
||||||
const handleAddCode = async () => {
|
const handleAddCode = async () => {
|
||||||
if (!formData.code.trim()) {
|
if (!formData.code.trim()) {
|
||||||
@@ -226,17 +281,12 @@ export function RedemptionCodesPage() {
|
|||||||
|
|
||||||
const handleViewRedemptions = (code: RedemptionCode) => {
|
const handleViewRedemptions = (code: RedemptionCode) => {
|
||||||
setViewingCode(code);
|
setViewingCode(code);
|
||||||
fetchRedemptions(code.id);
|
setRedemptionsPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredCodes = codes.filter(code => {
|
|
||||||
if (!search) return true;
|
const filteredCodes = codes;
|
||||||
const query = search.toLowerCase();
|
|
||||||
return (
|
|
||||||
code.code.toLowerCase().includes(query) ||
|
|
||||||
code.description?.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const getStatusBadge = (code: RedemptionCode) => {
|
const getStatusBadge = (code: RedemptionCode) => {
|
||||||
if (!code.is_active) {
|
if (!code.is_active) {
|
||||||
@@ -440,6 +490,20 @@ export function RedemptionCodesPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filteredCodes.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* View Redemptions Dialog */}
|
{/* View Redemptions Dialog */}
|
||||||
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
|
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
@@ -486,7 +550,21 @@ export function RedemptionCodesPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{redemptionsTotal > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={redemptionsPage}
|
||||||
|
pageSize={redemptionsPageSize}
|
||||||
|
totalCount={redemptionsTotal}
|
||||||
|
onPageChange={setRedemptionsPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setRedemptionsPage(1);
|
||||||
|
setRedemptionsPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
||||||
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
|
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -121,6 +122,18 @@ export function UserActivityPage() {
|
|||||||
const [matches, setMatches] = useState<MatchActivity[]>([]);
|
const [matches, setMatches] = useState<MatchActivity[]>([]);
|
||||||
const [messages, setMessages] = useState<MessageActivity[]>([]);
|
const [messages, setMessages] = useState<MessageActivity[]>([]);
|
||||||
const [signups, setSignups] = useState<SignupActivity[]>([]);
|
const [signups, setSignups] = useState<SignupActivity[]>([]);
|
||||||
|
const [responsesPage, setResponsesPage] = useState(1);
|
||||||
|
const [responsesPageSize, setResponsesPageSize] = useState(25);
|
||||||
|
const [responsesTotal, setResponsesTotal] = useState(0);
|
||||||
|
const [matchesPage, setMatchesPage] = useState(1);
|
||||||
|
const [matchesPageSize, setMatchesPageSize] = useState(25);
|
||||||
|
const [matchesTotal, setMatchesTotal] = useState(0);
|
||||||
|
const [messagesPage, setMessagesPage] = useState(1);
|
||||||
|
const [messagesPageSize, setMessagesPageSize] = useState(25);
|
||||||
|
const [messagesTotal, setMessagesTotal] = useState(0);
|
||||||
|
const [signupsPage, setSignupsPage] = useState(1);
|
||||||
|
const [signupsPageSize, setSignupsPageSize] = useState(25);
|
||||||
|
const [signupsTotal, setSignupsTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState({
|
const [loading, setLoading] = useState({
|
||||||
responses: true,
|
responses: true,
|
||||||
matches: true,
|
matches: true,
|
||||||
@@ -132,7 +145,10 @@ export function UserActivityPage() {
|
|||||||
const fetchResponses = useCallback(async () => {
|
const fetchResponses = useCallback(async () => {
|
||||||
setLoading(prev => ({ ...prev, responses: true }));
|
setLoading(prev => ({ ...prev, responses: true }));
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const from = (responsesPage - 1) * responsesPageSize;
|
||||||
|
const to = from + responsesPageSize - 1;
|
||||||
|
|
||||||
|
const { data, error, count } = await supabase
|
||||||
.from('responses')
|
.from('responses')
|
||||||
.select(`
|
.select(`
|
||||||
id, user_id, answer, created_at,
|
id, user_id, answer, created_at,
|
||||||
@@ -141,12 +157,14 @@ export function UserActivityPage() {
|
|||||||
id, text,
|
id, text,
|
||||||
pack:question_packs(name)
|
pack:question_packs(name)
|
||||||
)
|
)
|
||||||
`)
|
`, { count: 'exact' })
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(50);
|
.range(from, to);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
setResponsesTotal(count || 0);
|
||||||
|
|
||||||
// Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations)
|
// Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations)
|
||||||
const transformed: ResponseActivity[] = (data || []).map((item: any) => ({
|
const transformed: ResponseActivity[] = (data || []).map((item: any) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -167,23 +185,28 @@ export function UserActivityPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(prev => ({ ...prev, responses: false }));
|
setLoading(prev => ({ ...prev, responses: false }));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [responsesPage, responsesPageSize]);
|
||||||
|
|
||||||
// Fetch matches with couple profiles
|
// Fetch matches with couple profiles
|
||||||
const fetchMatches = useCallback(async () => {
|
const fetchMatches = useCallback(async () => {
|
||||||
setLoading(prev => ({ ...prev, matches: true }));
|
setLoading(prev => ({ ...prev, matches: true }));
|
||||||
try {
|
try {
|
||||||
const { data: matchesData, error: matchesError } = await supabase
|
const from = (matchesPage - 1) * matchesPageSize;
|
||||||
|
const to = from + matchesPageSize - 1;
|
||||||
|
|
||||||
|
const { data: matchesData, error: matchesError, count } = await supabase
|
||||||
.from('matches')
|
.from('matches')
|
||||||
.select(`
|
.select(`
|
||||||
id, couple_id, match_type, is_new, created_at,
|
id, couple_id, match_type, is_new, created_at,
|
||||||
question:questions(id, text)
|
question:questions(id, text)
|
||||||
`)
|
`, { count: 'exact' })
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(50);
|
.range(from, to);
|
||||||
|
|
||||||
if (matchesError) throw matchesError;
|
if (matchesError) throw matchesError;
|
||||||
|
|
||||||
|
setMatchesTotal(count || 0);
|
||||||
|
|
||||||
// Get unique couple IDs and fetch profiles
|
// Get unique couple IDs and fetch profiles
|
||||||
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
|
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
|
||||||
if (coupleIds.length > 0) {
|
if (coupleIds.length > 0) {
|
||||||
@@ -223,14 +246,17 @@ export function UserActivityPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(prev => ({ ...prev, matches: false }));
|
setLoading(prev => ({ ...prev, matches: false }));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [matchesPage, matchesPageSize]);
|
||||||
|
|
||||||
// Fetch messages
|
// Fetch messages
|
||||||
const fetchMessages = useCallback(async () => {
|
const fetchMessages = useCallback(async () => {
|
||||||
setLoading(prev => ({ ...prev, messages: true }));
|
setLoading(prev => ({ ...prev, messages: true }));
|
||||||
try {
|
try {
|
||||||
|
const from = (messagesPage - 1) * messagesPageSize;
|
||||||
|
const to = from + messagesPageSize - 1;
|
||||||
|
|
||||||
// Fetch messages with match data
|
// Fetch messages with match data
|
||||||
const { data: messagesData, error: messagesError } = await supabase
|
const { data: messagesData, error: messagesError, count } = await supabase
|
||||||
.from('messages')
|
.from('messages')
|
||||||
.select(`
|
.select(`
|
||||||
id, user_id, content, media_path, media_type, created_at,
|
id, user_id, content, media_path, media_type, created_at,
|
||||||
@@ -238,12 +264,14 @@ export function UserActivityPage() {
|
|||||||
id,
|
id,
|
||||||
question:questions(text)
|
question:questions(text)
|
||||||
)
|
)
|
||||||
`)
|
`, { count: 'exact' })
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(50);
|
.range(from, to);
|
||||||
|
|
||||||
if (messagesError) throw messagesError;
|
if (messagesError) throw messagesError;
|
||||||
|
|
||||||
|
setMessagesTotal(count || 0);
|
||||||
|
|
||||||
// Get unique user IDs and fetch profiles
|
// Get unique user IDs and fetch profiles
|
||||||
const userIds = [...new Set((messagesData || []).map((m: any) => m.user_id))];
|
const userIds = [...new Set((messagesData || []).map((m: any) => m.user_id))];
|
||||||
let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {};
|
let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {};
|
||||||
@@ -283,27 +311,31 @@ export function UserActivityPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(prev => ({ ...prev, messages: false }));
|
setLoading(prev => ({ ...prev, messages: false }));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [messagesPage, messagesPageSize]);
|
||||||
|
|
||||||
// Fetch signups
|
// Fetch signups
|
||||||
const fetchSignups = useCallback(async () => {
|
const fetchSignups = useCallback(async () => {
|
||||||
setLoading(prev => ({ ...prev, signups: true }));
|
setLoading(prev => ({ ...prev, signups: true }));
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const from = (signupsPage - 1) * signupsPageSize;
|
||||||
|
const to = from + signupsPageSize - 1;
|
||||||
|
|
||||||
|
const { data, error, count } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id')
|
.select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id', { count: 'exact' })
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(50);
|
.range(from, to);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setSignups(data || []);
|
setSignups(data || []);
|
||||||
|
setSignupsTotal(count || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load signups:', error);
|
console.error('Failed to load signups:', error);
|
||||||
toast.error("Failed to load signups");
|
toast.error("Failed to load signups");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(prev => ({ ...prev, signups: false }));
|
setLoading(prev => ({ ...prev, signups: false }));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [signupsPage, signupsPageSize]);
|
||||||
|
|
||||||
// Real-time subscriptions
|
// Real-time subscriptions
|
||||||
const { status: responsesStatus } = useRealtimeSubscription({
|
const { status: responsesStatus } = useRealtimeSubscription({
|
||||||
@@ -338,6 +370,34 @@ export function UserActivityPage() {
|
|||||||
fetchSignups();
|
fetchSignups();
|
||||||
}, [fetchResponses, fetchMatches, fetchMessages, fetchSignups]);
|
}, [fetchResponses, fetchMatches, fetchMessages, fetchSignups]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(responsesTotal / responsesPageSize));
|
||||||
|
if (responsesPage > totalPages) {
|
||||||
|
setResponsesPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [responsesPage, responsesPageSize, responsesTotal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(matchesTotal / matchesPageSize));
|
||||||
|
if (matchesPage > totalPages) {
|
||||||
|
setMatchesPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [matchesPage, matchesPageSize, matchesTotal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(messagesTotal / messagesPageSize));
|
||||||
|
if (messagesPage > totalPages) {
|
||||||
|
setMessagesPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [messagesPage, messagesPageSize, messagesTotal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(signupsTotal / signupsPageSize));
|
||||||
|
if (signupsPage > totalPages) {
|
||||||
|
setSignupsPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [signupsPage, signupsPageSize, signupsTotal]);
|
||||||
|
|
||||||
// Get current realtime status based on active tab
|
// Get current realtime status based on active tab
|
||||||
const getCurrentRealtimeStatus = () => {
|
const getCurrentRealtimeStatus = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
@@ -461,6 +521,19 @@ export function UserActivityPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{responsesTotal > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={responsesPage}
|
||||||
|
pageSize={responsesPageSize}
|
||||||
|
totalCount={responsesTotal}
|
||||||
|
onPageChange={setResponsesPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setResponsesPage(1);
|
||||||
|
setResponsesPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Matches Tab */}
|
{/* Matches Tab */}
|
||||||
@@ -535,6 +608,19 @@ export function UserActivityPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{matchesTotal > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={matchesPage}
|
||||||
|
pageSize={matchesPageSize}
|
||||||
|
totalCount={matchesTotal}
|
||||||
|
onPageChange={setMatchesPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setMatchesPage(1);
|
||||||
|
setMatchesPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Messages Tab */}
|
{/* Messages Tab */}
|
||||||
@@ -593,6 +679,19 @@ export function UserActivityPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{messagesTotal > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={messagesPage}
|
||||||
|
pageSize={messagesPageSize}
|
||||||
|
totalCount={messagesTotal}
|
||||||
|
onPageChange={setMessagesPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setMessagesPage(1);
|
||||||
|
setMessagesPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Signups Tab */}
|
{/* Signups Tab */}
|
||||||
@@ -666,6 +765,19 @@ export function UserActivityPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{signupsTotal > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={signupsPage}
|
||||||
|
pageSize={signupsPageSize}
|
||||||
|
totalCount={signupsTotal}
|
||||||
|
onPageChange={setSignupsPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setSignupsPage(1);
|
||||||
|
setSignupsPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
@@ -58,6 +59,8 @@ export function AdminsPage() {
|
|||||||
const [admins, setAdmins] = useState<AdminUser[]>([]);
|
const [admins, setAdmins] = useState<AdminUser[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
|
||||||
// Add Admin State
|
// Add Admin State
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
@@ -280,6 +283,23 @@ export function AdminsPage() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const totalCount = filteredAdmins.length;
|
||||||
|
const paginatedAdmins = filteredAdmins.slice(
|
||||||
|
(page - 1) * pageSize,
|
||||||
|
page * pageSize
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -509,7 +529,7 @@ export function AdminsPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredAdmins.map((admin) => (
|
paginatedAdmins.map((admin) => (
|
||||||
<TableRow key={admin.user_id}>
|
<TableRow key={admin.user_id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -590,6 +610,19 @@ export function AdminsPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filteredAdmins.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Edit Permissions Dialog */}
|
{/* Edit Permissions Dialog */}
|
||||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { IconPicker, IconPreview } from '@/components/ui/icon-picker';
|
import { IconPicker, IconPreview } from '@/components/ui/icon-picker';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -53,6 +54,9 @@ export function CategoriesPage() {
|
|||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [aiIdeasOpen, setAiIdeasOpen] = useState(false);
|
const [aiIdeasOpen, setAiIdeasOpen] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
// Form state using the new hook
|
// Form state using the new hook
|
||||||
const form = useEntityForm<CategoryFormData, Category>(
|
const form = useEntityForm<CategoryFormData, Category>(
|
||||||
@@ -71,25 +75,34 @@ export function CategoriesPage() {
|
|||||||
const fetchCategories = useCallback(async () => {
|
const fetchCategories = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch categories with pack count
|
const from = (page - 1) * pageSize;
|
||||||
const { data: cats, error } = await supabase
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
|
const { data: cats, error, count } = await supabase
|
||||||
.from('categories')
|
.from('categories')
|
||||||
.select('*')
|
.select('*', { count: 'exact' })
|
||||||
.order('sort_order', { ascending: true });
|
.order('sort_order', { ascending: true })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
// Fetch pack counts per category
|
setTotalCount(count || 0);
|
||||||
|
|
||||||
|
const categoryIds = (cats || []).map(c => c.id);
|
||||||
|
const packCounts: Record<string, number> = {};
|
||||||
|
|
||||||
|
if (categoryIds.length > 0) {
|
||||||
const { data: packs } = await supabase
|
const { data: packs } = await supabase
|
||||||
.from('question_packs')
|
.from('question_packs')
|
||||||
.select('category_id');
|
.select('category_id')
|
||||||
|
.in('category_id', categoryIds);
|
||||||
|
|
||||||
const packCounts: Record<string, number> = {};
|
|
||||||
packs?.forEach(p => {
|
packs?.forEach(p => {
|
||||||
if (p.category_id) {
|
if (p.category_id) {
|
||||||
packCounts[p.category_id] = (packCounts[p.category_id] || 0) + 1;
|
packCounts[p.category_id] = (packCounts[p.category_id] || 0) + 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setCategories(
|
setCategories(
|
||||||
(cats || []).map(c => ({
|
(cats || []).map(c => ({
|
||||||
@@ -103,12 +116,19 @@ export function CategoriesPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [page, pageSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
}, [fetchCategories]);
|
}, [fetchCategories]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Handlers
|
// Handlers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -143,7 +163,7 @@ export function CategoriesPage() {
|
|||||||
} else {
|
} else {
|
||||||
const { error } = await auditedSupabase.insert('categories', {
|
const { error } = await auditedSupabase.insert('categories', {
|
||||||
...data,
|
...data,
|
||||||
sort_order: categories.length,
|
sort_order: totalCount,
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
toast.success('Category created');
|
toast.success('Category created');
|
||||||
@@ -406,6 +426,19 @@ export function CategoriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* AI Dialog */}
|
{/* AI Dialog */}
|
||||||
<AiGeneratorDialog
|
<AiGeneratorDialog
|
||||||
open={aiIdeasOpen}
|
open={aiIdeasOpen}
|
||||||
|
|||||||
@@ -0,0 +1,578 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { supabase } from '@/config';
|
||||||
|
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||||
|
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { IconPicker, IconPreview } from '@/components/ui/icon-picker';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, Flame, Heart, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DarePack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
is_premium: boolean;
|
||||||
|
is_public: boolean;
|
||||||
|
is_explicit: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
category_id: string | null;
|
||||||
|
min_intensity: number | null;
|
||||||
|
max_intensity: number | null;
|
||||||
|
avg_intensity: number | null;
|
||||||
|
created_at: string | null;
|
||||||
|
dare_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DarePackFormData {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
is_premium: boolean;
|
||||||
|
is_public: boolean;
|
||||||
|
is_explicit: boolean;
|
||||||
|
category_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function DarePacksPage() {
|
||||||
|
// Data state
|
||||||
|
const [darePacks, setDarePacks] = useState<DarePack[]>([]);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
// Form state using the hook
|
||||||
|
const form = useEntityForm<DarePackFormData, DarePack>(
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
icon: 'flame-outline',
|
||||||
|
is_premium: false,
|
||||||
|
is_public: false,
|
||||||
|
is_explicit: false,
|
||||||
|
category_id: '',
|
||||||
|
},
|
||||||
|
(pack) => ({
|
||||||
|
name: pack.name,
|
||||||
|
description: pack.description || '',
|
||||||
|
icon: pack.icon || 'flame-outline',
|
||||||
|
is_premium: pack.is_premium,
|
||||||
|
is_public: pack.is_public,
|
||||||
|
is_explicit: pack.is_explicit,
|
||||||
|
category_id: pack.category_id || '',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Data Fetching
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
|
// Fetch dare packs
|
||||||
|
const { data: packData, error, count } = await supabase
|
||||||
|
.from('dare_packs')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.order('sort_order', { ascending: true })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
setTotalCount(count || 0);
|
||||||
|
|
||||||
|
// Fetch dare counts
|
||||||
|
const packIds = (packData || []).map(p => p.id);
|
||||||
|
const dareCounts: Record<string, number> = {};
|
||||||
|
|
||||||
|
if (packIds.length > 0) {
|
||||||
|
const { data: dares } = await supabase
|
||||||
|
.from('dares')
|
||||||
|
.select('pack_id')
|
||||||
|
.in('pack_id', packIds);
|
||||||
|
|
||||||
|
dares?.forEach(d => {
|
||||||
|
dareCounts[d.pack_id] = (dareCounts[d.pack_id] || 0) + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch categories
|
||||||
|
const { data: categoryData } = await supabase
|
||||||
|
.from('categories')
|
||||||
|
.select('id, name')
|
||||||
|
.order('name');
|
||||||
|
|
||||||
|
setCategories(categoryData || []);
|
||||||
|
|
||||||
|
setDarePacks(
|
||||||
|
(packData || []).map(p => ({
|
||||||
|
...p,
|
||||||
|
dare_count: dareCounts[p.id] || 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load dare packs');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Handlers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.formData.name.trim()) {
|
||||||
|
toast.error('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setSaving(true);
|
||||||
|
try {
|
||||||
|
if (form.editingItem) {
|
||||||
|
const { error } = await auditedSupabase.update('dare_packs', form.editingItem.id, {
|
||||||
|
name: form.formData.name,
|
||||||
|
description: form.formData.description || null,
|
||||||
|
icon: form.formData.icon || null,
|
||||||
|
is_premium: form.formData.is_premium,
|
||||||
|
is_public: form.formData.is_public,
|
||||||
|
is_explicit: form.formData.is_explicit,
|
||||||
|
category_id: form.formData.category_id || null,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
toast.success('Dare pack updated');
|
||||||
|
} else {
|
||||||
|
const { error } = await auditedSupabase.insert('dare_packs', {
|
||||||
|
name: form.formData.name,
|
||||||
|
description: form.formData.description || null,
|
||||||
|
icon: form.formData.icon || null,
|
||||||
|
is_premium: form.formData.is_premium,
|
||||||
|
is_public: form.formData.is_public,
|
||||||
|
is_explicit: form.formData.is_explicit,
|
||||||
|
category_id: form.formData.category_id || null,
|
||||||
|
sort_order: totalCount,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
toast.success('Dare pack created');
|
||||||
|
}
|
||||||
|
|
||||||
|
form.close();
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to save dare pack');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
form.setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (pack: DarePack) => {
|
||||||
|
if (!confirm(`Delete dare pack "${pack.name}"? This will delete all dares in this pack.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await auditedSupabase.delete('dare_packs', pack.id);
|
||||||
|
if (error) throw error;
|
||||||
|
toast.success('Dare pack deleted');
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to delete dare pack');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMove = async (pack: DarePack, direction: 'up' | 'down') => {
|
||||||
|
const currentIndex = darePacks.findIndex(p => p.id === pack.id);
|
||||||
|
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||||
|
|
||||||
|
if (targetIndex < 0 || targetIndex >= darePacks.length) return;
|
||||||
|
|
||||||
|
const targetPack = darePacks[targetIndex];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentOrder = pack.sort_order ?? currentIndex;
|
||||||
|
const targetOrder = targetPack.sort_order ?? targetIndex;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
auditedSupabase.update('dare_packs', pack.id, { sort_order: targetOrder }),
|
||||||
|
auditedSupabase.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
const newPacks = [...darePacks];
|
||||||
|
[newPacks[currentIndex], newPacks[targetIndex]] = [newPacks[targetIndex], newPacks[currentIndex]];
|
||||||
|
setDarePacks(newPacks);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to reorder dare packs');
|
||||||
|
console.error(error);
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIntensityColor = (avg: number | null) => {
|
||||||
|
if (avg === null) return 'bg-muted';
|
||||||
|
if (avg <= 1.5) return 'bg-green-500';
|
||||||
|
if (avg <= 2.5) return 'bg-lime-500';
|
||||||
|
if (avg <= 3.5) return 'bg-yellow-500';
|
||||||
|
if (avg <= 4.5) return 'bg-orange-500';
|
||||||
|
return 'bg-red-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Render
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton className="h-9 w-48" />
|
||||||
|
<Skeleton className="h-10 w-40" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<Skeleton key={i} className="h-48" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
<Zap className="h-8 w-8 text-orange-500" />
|
||||||
|
Dare Packs
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage dare packs that users can send to their partners
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={form.dialogOpen} onOpenChange={form.setDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={form.openCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Dare Pack
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{form.isEditing ? 'Edit Dare Pack' : 'Create Dare Pack'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{form.isEditing
|
||||||
|
? 'Update the dare pack details below.'
|
||||||
|
: 'Add a new dare pack for couples to challenge each other.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Icon</Label>
|
||||||
|
<IconPicker
|
||||||
|
value={form.formData.icon}
|
||||||
|
onChange={(iconName) => form.setField('icon', iconName)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={form.formData.name}
|
||||||
|
onChange={(e) => form.setField('name', e.target.value)}
|
||||||
|
placeholder="e.g., Romantic Dares"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={form.formData.description}
|
||||||
|
onChange={(e) => form.setField('description', e.target.value)}
|
||||||
|
placeholder="Describe this dare pack..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Category (optional)</Label>
|
||||||
|
<Select
|
||||||
|
value={form.formData.category_id || '__none__'}
|
||||||
|
onValueChange={(value) => form.setField('category_id', value === '__none__' ? '' : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">No category</SelectItem>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<SelectItem key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Premium</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Only available to premium users
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={form.formData.is_premium}
|
||||||
|
onCheckedChange={(checked) => form.setField('is_premium', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Published</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Visible to users in the app
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={form.formData.is_public}
|
||||||
|
onCheckedChange={(checked) => form.setField('is_public', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Explicit</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Contains adult/explicit content
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={form.formData.is_explicit}
|
||||||
|
onCheckedChange={(checked) => form.setField('is_explicit', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={form.close}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={form.saving}>
|
||||||
|
{form.saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dare Packs Grid */}
|
||||||
|
{darePacks.length === 0 ? (
|
||||||
|
<Card className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold">No dare packs yet</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Create your first dare pack to get started
|
||||||
|
</p>
|
||||||
|
<Button onClick={form.openCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Dare Pack
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{darePacks.map((pack, index) => (
|
||||||
|
<Card key={pack.id} className="group hover:shadow-md transition-shadow">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex flex-col opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => handleMove(pack, 'up')}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => handleMove(pack, 'down')}
|
||||||
|
disabled={index === darePacks.length - 1}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-4xl mb-2">
|
||||||
|
<IconPreview value={pack.icon} fallback="flame-outline" className="text-4xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{pack.is_premium && (
|
||||||
|
<Badge variant="default" className="bg-amber-500">
|
||||||
|
<Crown className="h-3 w-3 mr-1" />
|
||||||
|
Premium
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{pack.is_public ? (
|
||||||
|
<Badge variant="outline" className="border-green-500 text-green-400">
|
||||||
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
|
Published
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<EyeOff className="h-3 w-3 mr-1" />
|
||||||
|
Draft
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{pack.is_explicit ? (
|
||||||
|
<Badge variant="outline" className="border-red-500 text-red-400">
|
||||||
|
<Flame className="h-3 w-3 mr-1" />
|
||||||
|
Explicit
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="border-pink-400 text-pink-300">
|
||||||
|
<Heart className="h-3 w-3 mr-1" />
|
||||||
|
Safe
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<CardTitle className="flex-1">{pack.name}</CardTitle>
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => form.openEdit(pack)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(pack)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="line-clamp-2">
|
||||||
|
{pack.description || 'No description'}
|
||||||
|
</CardDescription>
|
||||||
|
{/* Intensity indicator */}
|
||||||
|
{pack.avg_intensity !== null && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Badge className={`${getIntensityColor(pack.avg_intensity)} text-white`}>
|
||||||
|
Avg: {pack.avg_intensity?.toFixed(1)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Range: {pack.min_intensity ?? '?'} - {pack.max_intensity ?? '?'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{pack.dare_count} dare{pack.dare_count !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<Link to={`/dare-packs/${pack.id}/dares`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Manage Dares
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{darePacks.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { supabase } from '@/config';
|
||||||
|
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Plus, Pencil, Trash2, Zap, Loader2, Clock } from 'lucide-react';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { IconPreview } from '@/components/ui/icon-picker';
|
||||||
|
import { INTENSITY_LEVELS } from '@/lib/openai';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface DarePack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
is_explicit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Dare {
|
||||||
|
id: string;
|
||||||
|
pack_id: string;
|
||||||
|
text: string;
|
||||||
|
intensity: number;
|
||||||
|
suggested_duration_hours: number | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration options for dares
|
||||||
|
const DURATION_OPTIONS = [
|
||||||
|
{ value: '', label: 'No suggestion' },
|
||||||
|
{ value: '1', label: '1 hour' },
|
||||||
|
{ value: '6', label: '6 hours' },
|
||||||
|
{ value: '12', label: '12 hours' },
|
||||||
|
{ value: '24', label: '24 hours' },
|
||||||
|
{ value: '72', label: '3 days' },
|
||||||
|
{ value: '168', label: '1 week' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper to get intensity info by level (1-indexed)
|
||||||
|
const getIntensity = (level: number) => INTENSITY_LEVELS[level - 1] || INTENSITY_LEVELS[0];
|
||||||
|
|
||||||
|
// Format duration for display
|
||||||
|
const formatDuration = (hours: number | null): string => {
|
||||||
|
if (hours === null) return '—';
|
||||||
|
if (hours < 24) return `${hours}h`;
|
||||||
|
if (hours < 168) return `${Math.round(hours / 24)}d`;
|
||||||
|
return `${Math.round(hours / 168)}w`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function DaresPage() {
|
||||||
|
const { packId } = useParams<{ packId: string }>();
|
||||||
|
const [pack, setPack] = useState<DarePack | null>(null);
|
||||||
|
const [dares, setDares] = useState<Dare[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingDare, setEditingDare] = useState<Dare | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [formData, setFormData] = useState<{
|
||||||
|
text: string;
|
||||||
|
intensity: number;
|
||||||
|
suggested_duration_hours: string;
|
||||||
|
}>({
|
||||||
|
text: '',
|
||||||
|
intensity: 1,
|
||||||
|
suggested_duration_hours: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (!packId) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Fetch pack info
|
||||||
|
const { data: packData } = await supabase
|
||||||
|
.from('dare_packs')
|
||||||
|
.select('id, name, description, icon, is_explicit')
|
||||||
|
.eq('id', packId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
setPack(packData);
|
||||||
|
|
||||||
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
|
// Fetch dares
|
||||||
|
const { data: dareData, error, count } = await supabase
|
||||||
|
.from('dares')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.eq('pack_id', packId)
|
||||||
|
.order('created_at', { ascending: true })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setTotalCount(count || 0);
|
||||||
|
setDares(dareData || []);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load dares');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [packId, page, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}, [packId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}, [page, pageSize]);
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
setEditingDare(null);
|
||||||
|
setFormData({ text: '', intensity: 1, suggested_duration_hours: '' });
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (dare: Dare) => {
|
||||||
|
setEditingDare(dare);
|
||||||
|
setFormData({
|
||||||
|
text: dare.text,
|
||||||
|
intensity: dare.intensity,
|
||||||
|
suggested_duration_hours: dare.suggested_duration_hours?.toString() || '',
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.text.trim()) {
|
||||||
|
toast.error('Dare text is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const dareData = {
|
||||||
|
text: formData.text,
|
||||||
|
intensity: formData.intensity,
|
||||||
|
suggested_duration_hours: formData.suggested_duration_hours
|
||||||
|
? parseInt(formData.suggested_duration_hours, 10)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingDare) {
|
||||||
|
const { error } = await auditedSupabase.update('dares', editingDare.id, dareData);
|
||||||
|
if (error) throw error;
|
||||||
|
toast.success('Dare updated');
|
||||||
|
} else {
|
||||||
|
const { error } = await auditedSupabase.insert('dares', {
|
||||||
|
...dareData,
|
||||||
|
pack_id: packId,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
toast.success('Dare created');
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogOpen(false);
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to save dare');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (dare: Dare) => {
|
||||||
|
if (!confirm('Delete this dare? This cannot be undone.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await auditedSupabase.delete('dares', dare.id);
|
||||||
|
if (error) throw error;
|
||||||
|
toast.success('Dare deleted');
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to delete dare');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Selection handlers
|
||||||
|
const toggleSelection = (id: string) => {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
setSelectedIds(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (selectedIds.size === dares.length) {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set(dares.map(d => d.id)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkDelete = async () => {
|
||||||
|
if (selectedIds.size === 0) return;
|
||||||
|
|
||||||
|
const count = selectedIds.size;
|
||||||
|
if (!confirm(`Delete ${count} dare${count !== 1 ? 's' : ''}? This cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBulkDeleting(true);
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(selectedIds).map(id => auditedSupabase.delete('dares', id))
|
||||||
|
);
|
||||||
|
toast.success(`${count} dare${count !== 1 ? 's' : ''} deleted`);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to delete dares');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setBulkDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton className="h-9 w-64" />
|
||||||
|
<Skeleton className="h-10 w-40" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-96" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
{pack && (
|
||||||
|
<>
|
||||||
|
<span className="text-3xl">
|
||||||
|
<IconPreview value={pack.icon} fallback="flame-outline" className="text-3xl" />
|
||||||
|
</span>
|
||||||
|
{pack.name}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{totalCount} dare{totalCount !== 1 ? 's' : ''} in this pack
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={openCreateDialog}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Dare
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingDare ? 'Edit Dare' : 'Add Dare'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editingDare
|
||||||
|
? 'Update the dare details below.'
|
||||||
|
: 'Add a new dare to this pack.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="text">Dare Text</Label>
|
||||||
|
<Textarea
|
||||||
|
id="text"
|
||||||
|
value={formData.text}
|
||||||
|
onChange={(e) => setFormData(d => ({ ...d, text: e.target.value }))}
|
||||||
|
placeholder="e.g., Give your partner a 5-minute massage..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Intensity Level</Label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{INTENSITY_LEVELS.map((intensity) => (
|
||||||
|
<Button
|
||||||
|
key={intensity.level}
|
||||||
|
type="button"
|
||||||
|
variant={formData.intensity === intensity.level ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFormData(d => ({ ...d, intensity: intensity.level }))}
|
||||||
|
className={formData.intensity === intensity.level ? intensity.color : ''}
|
||||||
|
title={intensity.description}
|
||||||
|
>
|
||||||
|
{intensity.level} - {intensity.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Suggested Duration</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.suggested_duration_hours}
|
||||||
|
onValueChange={(value) => setFormData(d => ({ ...d, suggested_duration_hours: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a duration" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DURATION_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This is a suggestion shown to users when sending the dare. They can choose a different timeframe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk Action Bar */}
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<div className="flex items-center justify-between bg-muted/50 border rounded-lg px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{selectedIds.size} dare{selectedIds.size !== 1 ? 's' : ''} selected
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedIds(new Set())}
|
||||||
|
>
|
||||||
|
Clear selection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBulkDelete}
|
||||||
|
disabled={bulkDeleting}
|
||||||
|
>
|
||||||
|
{bulkDeleting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete {selectedIds.size} Dare{selectedIds.size !== 1 ? 's' : ''}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dares Table */}
|
||||||
|
{dares.length === 0 ? (
|
||||||
|
<Card className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold">No dares yet</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Add dares to this pack for couples to challenge each other
|
||||||
|
</p>
|
||||||
|
<Button onClick={openCreateDialog}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Dare
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-12">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.size === dares.length && dares.length > 0}
|
||||||
|
onCheckedChange={toggleSelectAll}
|
||||||
|
aria-label="Select all"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-12">#</TableHead>
|
||||||
|
<TableHead>Dare</TableHead>
|
||||||
|
<TableHead className="w-24">Intensity</TableHead>
|
||||||
|
<TableHead className="w-24">Duration</TableHead>
|
||||||
|
<TableHead className="w-24">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{dares.map((dare, index) => (
|
||||||
|
<TableRow
|
||||||
|
key={dare.id}
|
||||||
|
className={selectedIds.has(dare.id) ? 'bg-muted/50' : ''}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(dare.id)}
|
||||||
|
onCheckedChange={() => toggleSelection(dare.id)}
|
||||||
|
aria-label={`Select dare ${index + 1}`}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium text-muted-foreground">
|
||||||
|
{(page - 1) * pageSize + index + 1}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="line-clamp-2">{dare.text}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
className={`${getIntensity(dare.intensity).color} text-white`}
|
||||||
|
title={getIntensity(dare.intensity).description}
|
||||||
|
>
|
||||||
|
{getIntensity(dare.intensity).label}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{dare.suggested_duration_hours ? (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Clock className="h-3 w-3 mr-1" />
|
||||||
|
{formatDuration(dare.suggested_duration_hours)}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openEditDialog(dare)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(dare)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dares.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown } from 'lucide-react';
|
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||||
@@ -60,6 +61,9 @@ export function PacksPage() {
|
|||||||
const [extractTopicsPack, setExtractTopicsPack] = useState<QuestionPack | null>(null);
|
const [extractTopicsPack, setExtractTopicsPack] = useState<QuestionPack | null>(null);
|
||||||
const [allTopics, setAllTopics] = useState<Topic[]>([]);
|
const [allTopics, setAllTopics] = useState<Topic[]>([]);
|
||||||
const [selectedTopicIds, setSelectedTopicIds] = useState<Set<string>>(new Set());
|
const [selectedTopicIds, setSelectedTopicIds] = useState<Set<string>>(new Set());
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
// Form state using the hook
|
// Form state using the hook
|
||||||
const form = useEntityForm<PackFormData, QuestionPack>(
|
const form = useEntityForm<PackFormData, QuestionPack>(
|
||||||
@@ -98,30 +102,39 @@ export function PacksPage() {
|
|||||||
setCategory(cat);
|
setCategory(cat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
// Fetch packs
|
// Fetch packs
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from('question_packs')
|
.from('question_packs')
|
||||||
.select('*')
|
.select('*', { count: 'exact' })
|
||||||
.order('sort_order', { ascending: true });
|
.order('sort_order', { ascending: true })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
query = query.eq('category_id', categoryId);
|
query = query.eq('category_id', categoryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: packData, error } = await query;
|
const { data: packData, error, count } = await query;
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
setTotalCount(count || 0);
|
||||||
|
|
||||||
// Fetch question counts
|
// Fetch question counts
|
||||||
const packIds = (packData || []).map(p => p.id);
|
const packIds = (packData || []).map(p => p.id);
|
||||||
|
const questionCounts: Record<string, number> = {};
|
||||||
|
|
||||||
|
if (packIds.length > 0) {
|
||||||
const { data: questions } = await supabase
|
const { data: questions } = await supabase
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('pack_id')
|
.select('pack_id')
|
||||||
.in('pack_id', packIds);
|
.in('pack_id', packIds);
|
||||||
|
|
||||||
const questionCounts: Record<string, number> = {};
|
|
||||||
questions?.forEach(q => {
|
questions?.forEach(q => {
|
||||||
questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1;
|
questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch all topics
|
// Fetch all topics
|
||||||
const { data: topicsData } = await supabase
|
const { data: topicsData } = await supabase
|
||||||
@@ -131,13 +144,17 @@ export function PacksPage() {
|
|||||||
setAllTopics(topicsData || []);
|
setAllTopics(topicsData || []);
|
||||||
|
|
||||||
// Fetch pack_topics relationships
|
// Fetch pack_topics relationships
|
||||||
const { data: packTopicsData } = await supabase
|
let packTopicsData: any[] = [];
|
||||||
|
if (packIds.length > 0) {
|
||||||
|
const { data } = await supabase
|
||||||
.from('pack_topics')
|
.from('pack_topics')
|
||||||
.select('pack_id, topic_id, topics(id, name)')
|
.select('pack_id, topic_id, topics(id, name)')
|
||||||
.in('pack_id', packIds);
|
.in('pack_id', packIds);
|
||||||
|
packTopicsData = data || [];
|
||||||
|
}
|
||||||
|
|
||||||
const packTopicsMap: Record<string, Topic[]> = {};
|
const packTopicsMap: Record<string, Topic[]> = {};
|
||||||
packTopicsData?.forEach((pt: any) => {
|
packTopicsData.forEach((pt: any) => {
|
||||||
if (!packTopicsMap[pt.pack_id]) {
|
if (!packTopicsMap[pt.pack_id]) {
|
||||||
packTopicsMap[pt.pack_id] = [];
|
packTopicsMap[pt.pack_id] = [];
|
||||||
}
|
}
|
||||||
@@ -159,12 +176,19 @@ export function PacksPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [categoryId]);
|
}, [categoryId, page, pageSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Handlers
|
// Handlers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -211,7 +235,7 @@ export function PacksPage() {
|
|||||||
is_public: form.formData.is_public,
|
is_public: form.formData.is_public,
|
||||||
is_explicit: form.formData.is_explicit,
|
is_explicit: form.formData.is_explicit,
|
||||||
category_id: categoryId || null,
|
category_id: categoryId || null,
|
||||||
sort_order: packs.length,
|
sort_order: totalCount,
|
||||||
})
|
})
|
||||||
.select('id')
|
.select('id')
|
||||||
.single();
|
.single();
|
||||||
@@ -535,6 +559,19 @@ export function PacksPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{packs.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -70,6 +71,9 @@ export function QuestionsPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState<{
|
const [formData, setFormData] = useState<{
|
||||||
@@ -100,14 +104,19 @@ export function QuestionsPage() {
|
|||||||
|
|
||||||
setPack(packData);
|
setPack(packData);
|
||||||
|
|
||||||
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
// Fetch questions
|
// Fetch questions
|
||||||
const { data: questionData, error } = await supabase
|
const { data: questionData, error, count } = await supabase
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('*')
|
.select('*', { count: 'exact' })
|
||||||
.eq('pack_id', packId)
|
.eq('pack_id', packId)
|
||||||
.order('created_at', { ascending: true });
|
.order('created_at', { ascending: true })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
setTotalCount(count || 0);
|
||||||
setQuestions(questionData || []);
|
setQuestions(questionData || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to load questions');
|
toast.error('Failed to load questions');
|
||||||
@@ -115,33 +124,49 @@ export function QuestionsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [packId]);
|
}, [packId, page, pageSize]);
|
||||||
|
|
||||||
// Real-time subscription for questions in this pack
|
// Real-time subscription for questions in this pack
|
||||||
const { status: realtimeStatus } = useRealtimeSubscription<Question>({
|
const { status: realtimeStatus } = useRealtimeSubscription<Question>({
|
||||||
table: 'questions',
|
table: 'questions',
|
||||||
filter: packId ? `pack_id=eq.${packId}` : undefined,
|
filter: packId ? `pack_id=eq.${packId}` : undefined,
|
||||||
enabled: !!packId,
|
enabled: !!packId,
|
||||||
onInsert: useCallback((newQuestion: Question) => {
|
onInsert: useCallback(() => {
|
||||||
setQuestions(prev => [...prev, newQuestion]);
|
fetchData();
|
||||||
}, []),
|
}, [fetchData]),
|
||||||
onUpdate: useCallback(({ new: updated }: { old: Question; new: Question }) => {
|
onUpdate: useCallback(() => {
|
||||||
setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q));
|
fetchData();
|
||||||
}, []),
|
}, [fetchData]),
|
||||||
onDelete: useCallback((deleted: Question) => {
|
onDelete: useCallback((deleted: Question) => {
|
||||||
setQuestions(prev => prev.filter(q => q.id !== deleted.id));
|
|
||||||
setSelectedIds(prev => {
|
setSelectedIds(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(deleted.id);
|
next.delete(deleted.id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []),
|
fetchData();
|
||||||
|
}, [fetchData]),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}, [packId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}, [page, pageSize]);
|
||||||
|
|
||||||
const openCreateDialog = () => {
|
const openCreateDialog = () => {
|
||||||
setEditingQuestion(null);
|
setEditingQuestion(null);
|
||||||
setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] });
|
setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] });
|
||||||
@@ -319,7 +344,7 @@ export function QuestionsPage() {
|
|||||||
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{questions.length} question{questions.length !== 1 ? 's' : ''} in this pack
|
{totalCount} question{totalCount !== 1 ? 's' : ''} in this pack
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -615,7 +640,7 @@ export function QuestionsPage() {
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium text-muted-foreground">
|
<TableCell className="font-medium text-muted-foreground">
|
||||||
{index + 1}
|
{(page - 1) * pageSize + index + 1}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="line-clamp-2">{question.text}</span>
|
<span className="line-clamp-2">{question.text}</span>
|
||||||
@@ -689,6 +714,19 @@ export function QuestionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{questions.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div >
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import { format, isToday, isYesterday } from 'date-fns';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
|
|
||||||
interface Profile {
|
interface Profile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -331,25 +332,11 @@ export function MatchChatPage() {
|
|||||||
const [match, setMatch] = useState<Match | null>(null);
|
const [match, setMatch] = useState<Match | null>(null);
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(50);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
// Real-time subscription for messages in this chat
|
const fetchData = useCallback(async () => {
|
||||||
const { status: realtimeStatus } = useRealtimeSubscription<Message>({
|
|
||||||
table: 'messages',
|
|
||||||
filter: matchId ? `match_id=eq.${matchId}` : undefined,
|
|
||||||
enabled: !!matchId,
|
|
||||||
onInsert: useCallback((newMessage: Message) => {
|
|
||||||
setMessages(prev => [...prev, newMessage]);
|
|
||||||
}, []),
|
|
||||||
onUpdate: useCallback(({ new: updated }: { old: Message; new: Message }) => {
|
|
||||||
setMessages(prev => prev.map(m => m.id === updated.id ? updated : m));
|
|
||||||
}, []),
|
|
||||||
onDelete: useCallback((deleted: Message) => {
|
|
||||||
setMessages(prev => prev.filter(m => m.id !== deleted.id));
|
|
||||||
}, []),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
if (!userId || !matchId) return;
|
if (!userId || !matchId) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -373,6 +360,8 @@ export function MatchChatPage() {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
setPartner(partnerData);
|
setPartner(partnerData);
|
||||||
|
} else {
|
||||||
|
setPartner(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch match with question
|
// Fetch match with question
|
||||||
@@ -387,23 +376,59 @@ export function MatchChatPage() {
|
|||||||
|
|
||||||
setMatch(matchData as unknown as Match);
|
setMatch(matchData as unknown as Match);
|
||||||
|
|
||||||
// Fetch messages
|
const from = (page - 1) * pageSize;
|
||||||
const { data: messageData } = await supabase
|
const to = from + pageSize - 1;
|
||||||
.from('messages')
|
|
||||||
.select('*')
|
|
||||||
.eq('match_id', matchId)
|
|
||||||
.order('created_at', { ascending: true });
|
|
||||||
|
|
||||||
setMessages(messageData || []);
|
// Fetch messages
|
||||||
|
const { data: messageData, error: messagesError, count } = await supabase
|
||||||
|
.from('messages')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.eq('match_id', matchId)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
|
if (messagesError) throw messagesError;
|
||||||
|
|
||||||
|
setTotalCount(count || 0);
|
||||||
|
const orderedMessages = (messageData || []).slice().reverse();
|
||||||
|
setMessages(orderedMessages);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load chat data:', error);
|
console.error('Failed to load chat data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [userId, matchId, page, pageSize]);
|
||||||
|
|
||||||
|
// Real-time subscription for messages in this chat
|
||||||
|
const { status: realtimeStatus } = useRealtimeSubscription<Message>({
|
||||||
|
table: 'messages',
|
||||||
|
filter: matchId ? `match_id=eq.${matchId}` : undefined,
|
||||||
|
enabled: !!matchId,
|
||||||
|
onInsert: useCallback(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [userId, matchId]);
|
}, [fetchData]),
|
||||||
|
onUpdate: useCallback(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]),
|
||||||
|
onDelete: useCallback(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [matchId]);
|
||||||
|
|
||||||
const getProfile = (senderId: string): Profile | null => {
|
const getProfile = (senderId: string): Profile | null => {
|
||||||
if (senderId === user?.id) return user;
|
if (senderId === user?.id) return user;
|
||||||
@@ -497,7 +522,7 @@ export function MatchChatPage() {
|
|||||||
<Card className="min-h-[400px]">
|
<Card className="min-h-[400px]">
|
||||||
<CardHeader className="py-4 border-b">
|
<CardHeader className="py-4 border-b">
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle className="text-sm font-medium">
|
||||||
Messages ({messages.length})
|
Messages ({totalCount})
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
@@ -580,6 +605,21 @@ export function MatchChatPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
@@ -237,6 +238,15 @@ export function UserDetailPage() {
|
|||||||
const [responses, setResponses] = useState<Response[]>([]);
|
const [responses, setResponses] = useState<Response[]>([]);
|
||||||
const [matches, setMatches] = useState<Match[]>([]);
|
const [matches, setMatches] = useState<Match[]>([]);
|
||||||
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
|
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
|
||||||
|
const [responsesPage, setResponsesPage] = useState(1);
|
||||||
|
const [responsesPageSize, setResponsesPageSize] = useState(10);
|
||||||
|
const [responsesTotal, setResponsesTotal] = useState(0);
|
||||||
|
const [matchesPage, setMatchesPage] = useState(1);
|
||||||
|
const [matchesPageSize, setMatchesPageSize] = useState(10);
|
||||||
|
const [matchesTotal, setMatchesTotal] = useState(0);
|
||||||
|
const [mediaPage, setMediaPage] = useState(1);
|
||||||
|
const [mediaPageSize, setMediaPageSize] = useState(12);
|
||||||
|
const [mediaTotal, setMediaTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
|
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
|
||||||
@@ -308,44 +318,121 @@ export function UserDetailPage() {
|
|||||||
setPartner(null);
|
setPartner(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: responseData } = await supabase.from('responses').select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))').eq('user_id', userId).order('created_at', { ascending: false }).limit(50);
|
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
||||||
|
const responsesTo = responsesFrom + responsesPageSize - 1;
|
||||||
|
|
||||||
|
const { data: responseData, count: responsesCount, error: responsesError } = await supabase
|
||||||
|
.from('responses')
|
||||||
|
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))', { count: 'exact' })
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.range(responsesFrom, responsesTo);
|
||||||
|
|
||||||
|
if (responsesError) throw responsesError;
|
||||||
|
|
||||||
setResponses((responseData || []) as unknown as Response[]);
|
setResponses((responseData || []) as unknown as Response[]);
|
||||||
|
setResponsesTotal(responsesCount || 0);
|
||||||
|
|
||||||
if (profileData?.couple_id) {
|
if (profileData?.couple_id) {
|
||||||
const { data: matchData } = await supabase.from('matches').select('id, match_type, is_new, created_at, question:questions(id, text)').eq('couple_id', profileData.couple_id).order('created_at', { ascending: false }).limit(50);
|
const matchesFrom = (matchesPage - 1) * matchesPageSize;
|
||||||
|
const matchesTo = matchesFrom + matchesPageSize - 1;
|
||||||
|
|
||||||
|
const { data: matchData, count: matchesCount, error: matchesError } = await supabase
|
||||||
|
.from('matches')
|
||||||
|
.select('id, match_type, is_new, created_at, question:questions(id, text)', { count: 'exact' })
|
||||||
|
.eq('couple_id', profileData.couple_id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.range(matchesFrom, matchesTo);
|
||||||
|
|
||||||
|
if (matchesError) throw matchesError;
|
||||||
|
|
||||||
|
setMatchesTotal(matchesCount || 0);
|
||||||
|
|
||||||
const matchIds = (matchData || []).map(m => m.id);
|
const matchIds = (matchData || []).map(m => m.id);
|
||||||
if (matchIds.length > 0) {
|
if (matchIds.length > 0) {
|
||||||
const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds);
|
const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds);
|
||||||
const messageCounts: Record<string, number> = {};
|
const messageCounts: Record<string, number> = {};
|
||||||
messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; });
|
messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; });
|
||||||
setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]);
|
setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]);
|
||||||
const { data: mediaMessagesData, error: mediaError } = await supabase.from('messages').select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id').in('match_id', matchIds).not('media_path', 'is', null).order('created_at', { ascending: false });
|
} else {
|
||||||
|
setMatches([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: allMatchIdsData } = await supabase
|
||||||
|
.from('matches')
|
||||||
|
.select('id')
|
||||||
|
.eq('couple_id', profileData.couple_id);
|
||||||
|
|
||||||
|
const allMatchIds = (allMatchIdsData || []).map(m => m.id);
|
||||||
|
if (allMatchIds.length > 0) {
|
||||||
|
const mediaFrom = (mediaPage - 1) * mediaPageSize;
|
||||||
|
const mediaTo = mediaFrom + mediaPageSize - 1;
|
||||||
|
|
||||||
|
const { data: mediaMessagesData, error: mediaError, count: mediaCount } = await supabase
|
||||||
|
.from('messages')
|
||||||
|
.select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id', { count: 'exact' })
|
||||||
|
.in('match_id', allMatchIds)
|
||||||
|
.not('media_path', 'is', null)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.range(mediaFrom, mediaTo);
|
||||||
|
|
||||||
if (mediaError) {
|
if (mediaError) {
|
||||||
console.error("Error fetching media messages:", mediaError);
|
console.error("Error fetching media messages:", mediaError);
|
||||||
setMediaMessages([]);
|
setMediaMessages([]);
|
||||||
|
setMediaTotal(0);
|
||||||
} else {
|
} else {
|
||||||
setMediaMessages(mediaMessagesData || []);
|
setMediaMessages(mediaMessagesData || []);
|
||||||
|
setMediaTotal(mediaCount || 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setMediaMessages([]);
|
||||||
|
setMediaTotal(0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setMatches([]);
|
setMatches([]);
|
||||||
|
setMatchesTotal(0);
|
||||||
setMediaMessages([]);
|
setMediaMessages([]);
|
||||||
}
|
setMediaTotal(0);
|
||||||
} else {
|
|
||||||
setMatches([]);
|
|
||||||
setMediaMessages([]);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load user data:', error);
|
console.error('Failed to load user data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [userId]);
|
}, [userId, responsesPage, responsesPageSize, matchesPage, matchesPageSize, mediaPage, mediaPageSize]);
|
||||||
|
|
||||||
const { status: profileStatus } = useRealtimeSubscription<Profile>({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) });
|
const { status: profileStatus } = useRealtimeSubscription<Profile>({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) });
|
||||||
const { status: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData });
|
const { status: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData });
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setResponsesPage(1);
|
||||||
|
setMatchesPage(1);
|
||||||
|
setMediaPage(1);
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(responsesTotal / responsesPageSize));
|
||||||
|
if (responsesPage > totalPages) {
|
||||||
|
setResponsesPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [responsesPage, responsesPageSize, responsesTotal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(matchesTotal / matchesPageSize));
|
||||||
|
if (matchesPage > totalPages) {
|
||||||
|
setMatchesPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [matchesPage, matchesPageSize, matchesTotal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(mediaTotal / mediaPageSize));
|
||||||
|
if (mediaPage > totalPages) {
|
||||||
|
setMediaPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [mediaPage, mediaPageSize, mediaTotal]);
|
||||||
|
|
||||||
if (loading) return <div className="space-y-6"><Skeleton className="h-32" /><Skeleton className="h-96" /></div>;
|
if (loading) return <div className="space-y-6"><Skeleton className="h-32" /><Skeleton className="h-96" /></div>;
|
||||||
if (!profile) return <div className="flex flex-col items-center justify-center py-12"><p className="text-lg font-medium">User not found</p><Link to="/users"><Button variant="link">Back to users</Button></Link></div>;
|
if (!profile) return <div className="flex flex-col items-center justify-center py-12"><p className="text-lg font-medium">User not found</p><Link to="/users"><Button variant="link">Back to users</Button></Link></div>;
|
||||||
|
|
||||||
@@ -378,19 +465,19 @@ export function UserDetailPage() {
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
{canViewResponses && <TabsTrigger value="responses">Responses ({responses.length})</TabsTrigger>}
|
{canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
|
||||||
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matches.length})</TabsTrigger>}
|
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
|
||||||
{canViewMedia && <TabsTrigger value="media">Media ({mediaMessages.length})</TabsTrigger>}
|
{canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div>}</TabsContent>}
|
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
|
||||||
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div>}</TabsContent>}
|
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
|
||||||
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
|
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
|
||||||
{message.media_type === 'video' ? (
|
{message.media_type === 'video' ? (
|
||||||
<AdminDecryptedVideo messageId={message.id} />
|
<AdminDecryptedVideo messageId={message.id} />
|
||||||
) : (
|
) : (
|
||||||
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
|
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
|
||||||
)}
|
)}
|
||||||
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div></div>}</TabsContent>}
|
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||||
@@ -46,16 +47,20 @@ export function UsersPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filteredProfiles, setFilteredProfiles] = useState<Profile[]>([]);
|
const [filteredProfiles, setFilteredProfiles] = useState<Profile[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
|
||||||
const fetchProfiles = useCallback(async () => {
|
const fetchProfiles = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch profiles with auth info (includes last_sign_in_at)
|
// Fetch profiles with auth info (includes last_sign_in_at)
|
||||||
const { data: profilesData, error } = await supabase
|
const { data: profilesDataRaw, error } = await supabase
|
||||||
.rpc('get_profiles_with_auth_info');
|
.rpc('get_profiles_with_auth_info');
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
const profilesData = (profilesDataRaw || []) as Profile[];
|
||||||
|
|
||||||
// Fetch storage usage per user from storage.objects
|
// Fetch storage usage per user from storage.objects
|
||||||
const { data: storageData } = await supabase
|
const { data: storageData } = await supabase
|
||||||
.rpc('get_user_storage_usage');
|
.rpc('get_user_storage_usage');
|
||||||
@@ -70,7 +75,7 @@ export function UsersPage() {
|
|||||||
|
|
||||||
// Build a map of couple_id -> users with premium in that couple
|
// Build a map of couple_id -> users with premium in that couple
|
||||||
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
|
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
|
||||||
(profilesData || []).forEach(profile => {
|
profilesData.forEach(profile => {
|
||||||
if (profile.couple_id) {
|
if (profile.couple_id) {
|
||||||
if (!couplePartners[profile.couple_id]) {
|
if (!couplePartners[profile.couple_id]) {
|
||||||
couplePartners[profile.couple_id] = [];
|
couplePartners[profile.couple_id] = [];
|
||||||
@@ -83,7 +88,7 @@ export function UsersPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Merge storage data and partner premium status with profiles
|
// Merge storage data and partner premium status with profiles
|
||||||
const profilesWithExtras = (profilesData || []).map(profile => {
|
const profilesWithExtras = profilesData.map(profile => {
|
||||||
// Check if partner has premium
|
// Check if partner has premium
|
||||||
let partnerIsPremium = false;
|
let partnerIsPremium = false;
|
||||||
if (profile.couple_id && couplePartners[profile.couple_id]) {
|
if (profile.couple_id && couplePartners[profile.couple_id]) {
|
||||||
@@ -134,6 +139,7 @@ export function UsersPage() {
|
|||||||
}, [fetchProfiles]);
|
}, [fetchProfiles]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
if (!search.trim()) {
|
if (!search.trim()) {
|
||||||
setFilteredProfiles(profiles);
|
setFilteredProfiles(profiles);
|
||||||
return;
|
return;
|
||||||
@@ -150,6 +156,19 @@ export function UsersPage() {
|
|||||||
);
|
);
|
||||||
}, [search, profiles]);
|
}, [search, profiles]);
|
||||||
|
|
||||||
|
const totalCount = filteredProfiles.length;
|
||||||
|
const paginatedProfiles = filteredProfiles.slice(
|
||||||
|
(page - 1) * pageSize,
|
||||||
|
page * pageSize
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -209,7 +228,7 @@ export function UsersPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredProfiles.map((profile) => (
|
paginatedProfiles.map((profile) => (
|
||||||
<TableRow key={profile.id}>
|
<TableRow key={profile.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link to={`/users/${profile.id}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
<Link to={`/users/${profile.id}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
||||||
@@ -294,6 +313,19 @@ export function UsersPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filteredProfiles.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ android {
|
|||||||
applicationId 'com.sauci.app'
|
applicationId 'com.sauci.app'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 17
|
versionCode 21
|
||||||
versionName "1.0.0"
|
versionName "1.0.0"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": false,
|
"supportsTablet": false,
|
||||||
"bundleIdentifier": "com.sauci.app",
|
"bundleIdentifier": "com.sauci.app",
|
||||||
"buildNumber": "24",
|
"buildNumber": "25",
|
||||||
"usesAppleSignIn": true,
|
"usesAppleSignIn": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"UIBackgroundModes": [
|
"UIBackgroundModes": [
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
"backgroundColor": "#1a1a2e"
|
"backgroundColor": "#1a1a2e"
|
||||||
},
|
},
|
||||||
"package": "com.sauci.app",
|
"package": "com.sauci.app",
|
||||||
"versionCode": 20,
|
"versionCode": 21,
|
||||||
"googleServicesFile": "./google-services.json",
|
"googleServicesFile": "./google-services.json",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const ACCENT_COLOR = colors.primary;
|
|||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { user, partner, couple } = useAuthStore();
|
const { user, partner, couple } = useAuthStore();
|
||||||
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
|
const { matches, newMatchesCount, totalCount, fetchMatches } = useMatchStore();
|
||||||
const { enabledPackIds, fetchPacks } = usePacksStore();
|
const { enabledPackIds, fetchPacks } = usePacksStore();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ export default function HomeScreen() {
|
|||||||
color={matches.length > 0 ? colors.text : ACCENT_COLOR}
|
color={matches.length > 0 ? colors.text : ACCENT_COLOR}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.statNumber}>{matches.length}</Text>
|
<Text style={styles.statNumber}>{totalCount ?? matches.length}</Text>
|
||||||
<Text style={styles.statLabel}>Matches</Text>
|
<Text style={styles.statLabel}>Matches</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
|
|||||||
type GroupByOption = "pack" | "date" | "answer";
|
type GroupByOption = "pack" | "date" | "answer";
|
||||||
|
|
||||||
export default function MyAnswersScreen() {
|
export default function MyAnswersScreen() {
|
||||||
const { responses, isLoading, isLoadingMore, hasMore, groupBy, fetchResponses, setGroupBy, totalCount } = useResponsesStore();
|
const { responses, isLoading, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
@@ -123,7 +123,7 @@ export default function MyAnswersScreen() {
|
|||||||
}
|
}
|
||||||
}, [isLoading, isLoadingMore, hasMore]);
|
}, [isLoading, isLoadingMore, hasMore]);
|
||||||
|
|
||||||
const sections = groupResponses(responses, groupBy);
|
const sections = groupResponses(responses, groupBy, dateSortOrder);
|
||||||
|
|
||||||
const handleEditPress = (response: ResponseWithQuestion) => {
|
const handleEditPress = (response: ResponseWithQuestion) => {
|
||||||
setSelectedResponse(response);
|
setSelectedResponse(response);
|
||||||
@@ -164,16 +164,39 @@ export default function MyAnswersScreen() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => (
|
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => {
|
||||||
|
const isActive = groupBy === option;
|
||||||
|
const isDateButton = option === "date";
|
||||||
|
const showChevron = isDateButton && isActive;
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (isDateButton && isActive) {
|
||||||
|
// Toggle sort order when date is already selected
|
||||||
|
toggleDateSortOrder();
|
||||||
|
} else {
|
||||||
|
setGroupBy(option);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.groupByButton, groupBy === option && styles.groupByButtonActive]}
|
style={[styles.groupByButton, isActive && styles.groupByButtonActive]}
|
||||||
onPress={() => setGroupBy(option)}
|
onPress={handlePress}
|
||||||
>
|
>
|
||||||
<Text style={[styles.groupByText, groupBy === option && styles.groupByTextActive]}>
|
<Text style={[styles.groupByText, isActive && styles.groupByTextActive]}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
|
{showChevron && (
|
||||||
|
<Ionicons
|
||||||
|
name={dateSortOrder === "newest" ? "chevron-down" : "chevron-up"}
|
||||||
|
size={14}
|
||||||
|
color={ACCENT}
|
||||||
|
style={styles.sortChevron}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
@@ -441,6 +464,8 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: spacing.md,
|
marginTop: spacing.md,
|
||||||
},
|
},
|
||||||
groupByButton: {
|
groupByButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
@@ -461,6 +486,9 @@ const styles = StyleSheet.create({
|
|||||||
color: ACCENT,
|
color: ACCENT,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
},
|
},
|
||||||
|
sortChevron: {
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
},
|
||||||
// Section header
|
// Section header
|
||||||
sectionHeader: {
|
sectionHeader: {
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20</string>
|
<string>25</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0</string>
|
<string>12.0</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Sauci uses your camera to take photos and videos that you can share privately with your partner in encrypted chat messages.</string>
|
<string>Sauci uses your camera to take photos and videos that you can share privately with your partner.</string>
|
||||||
<key>NSFaceIDUsageDescription</key>
|
<key>NSFaceIDUsageDescription</key>
|
||||||
<string>Sauci uses Face ID to unlock the app and keep your conversations private.</string>
|
<string>Sauci uses Face ID to unlock the app and keep your conversations private.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
<string>Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.</string>
|
<string>Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Sauci uses your photo library so you can select photos and videos to share privately with your partner in encrypted chat messages.</string>
|
<string>Sauci uses your photo library so you can select photos and videos to share privately with your partner.</string>
|
||||||
<key>NSUserActivityTypes</key>
|
<key>NSUserActivityTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
if (!matchId) return;
|
if (!matchId) return;
|
||||||
|
|
||||||
const fetchMatch = async () => {
|
const fetchMatch = async () => {
|
||||||
|
setMatch(null); // Clear previous match data immediately
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from("matches")
|
.from("matches")
|
||||||
.select("*, question:questions(*)")
|
.select("*, question:questions(*)")
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export const useMessageSubscription = (
|
|||||||
|
|
||||||
const fetchMessagesAndMarkRead = async () => {
|
const fetchMessagesAndMarkRead = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setAllMessages([]); // Clear previous chat's messages immediately
|
||||||
|
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from('messages')
|
.from('messages')
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import type { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
type IoniconsName = keyof typeof Ionicons.glyphMap;
|
type IoniconsName = keyof typeof Ionicons.glyphMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a string is a valid Ionicons name.
|
||||||
|
* Uses the actual Ionicons.glyphMap for runtime validation.
|
||||||
|
*/
|
||||||
|
function isValidIonicon(name: string): name is IoniconsName {
|
||||||
|
return name in Ionicons.glyphMap;
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy emoji mappings (for backwards compatibility with existing packs)
|
// Legacy emoji mappings (for backwards compatibility with existing packs)
|
||||||
const EMOJI_TO_IONICON: Record<string, IoniconsName> = {
|
const EMOJI_TO_IONICON: Record<string, IoniconsName> = {
|
||||||
// Career & Professional
|
// Career & Professional
|
||||||
@@ -49,63 +57,20 @@ export const PACK_ICON_MAP = EMOJI_TO_IONICON;
|
|||||||
|
|
||||||
export const DEFAULT_PACK_ICON: IoniconsName = "layers-outline";
|
export const DEFAULT_PACK_ICON: IoniconsName = "layers-outline";
|
||||||
|
|
||||||
// List of valid Ionicon names (subset we support)
|
|
||||||
const VALID_IONICONS = new Set([
|
|
||||||
"heart-outline",
|
|
||||||
"heart-half-outline",
|
|
||||||
"heart-circle-outline",
|
|
||||||
"chatbubbles-outline",
|
|
||||||
"chatbox-outline",
|
|
||||||
"mail-outline",
|
|
||||||
"flower-outline",
|
|
||||||
"wine-outline",
|
|
||||||
"restaurant-outline",
|
|
||||||
"cafe-outline",
|
|
||||||
"airplane-outline",
|
|
||||||
"car-outline",
|
|
||||||
"compass-outline",
|
|
||||||
"map-outline",
|
|
||||||
"home-outline",
|
|
||||||
"people-outline",
|
|
||||||
"person-outline",
|
|
||||||
"eye-off-outline",
|
|
||||||
"key-outline",
|
|
||||||
"lock-closed-outline",
|
|
||||||
"dice-outline",
|
|
||||||
"gift-outline",
|
|
||||||
"sparkles-outline",
|
|
||||||
"star-outline",
|
|
||||||
"flame-outline",
|
|
||||||
"flash-outline",
|
|
||||||
"moon-outline",
|
|
||||||
"sunny-outline",
|
|
||||||
"flag-outline",
|
|
||||||
"calendar-outline",
|
|
||||||
"checkbox-outline",
|
|
||||||
"trophy-outline",
|
|
||||||
"layers-outline",
|
|
||||||
"cube-outline",
|
|
||||||
"folder-outline",
|
|
||||||
"bookmark-outline",
|
|
||||||
"bulb-outline",
|
|
||||||
"color-wand-outline",
|
|
||||||
"sync-outline",
|
|
||||||
"refresh-outline",
|
|
||||||
"briefcase-outline",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts an icon value to an Ionicons name.
|
* Converts an icon value to an Ionicons name.
|
||||||
* Handles both:
|
* Handles both:
|
||||||
* - Ionicon names directly (new packs): "briefcase-outline" -> "briefcase-outline"
|
* - Any valid Ionicon name directly: "briefcase-outline" -> "briefcase-outline"
|
||||||
* - Legacy emojis (existing packs): "💼" -> "briefcase-outline"
|
* - Legacy emojis (existing packs): "💼" -> "briefcase-outline"
|
||||||
*/
|
*/
|
||||||
export function getPackIconName(icon: string | null | undefined): IoniconsName {
|
export function getPackIconName(icon: string | null | undefined): IoniconsName {
|
||||||
if (!icon) return DEFAULT_PACK_ICON;
|
if (!icon) return DEFAULT_PACK_ICON;
|
||||||
|
|
||||||
if (VALID_IONICONS.has(icon)) {
|
// Check if it's a valid Ionicon name (supports the full Ionicons set)
|
||||||
return icon as IoniconsName;
|
if (isValidIonicon(icon)) {
|
||||||
|
return icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to emoji mapping for legacy packs
|
||||||
return EMOJI_TO_IONICON[icon] || DEFAULT_PACK_ICON;
|
return EMOJI_TO_IONICON[icon] || DEFAULT_PACK_ICON;
|
||||||
}
|
}
|
||||||
@@ -5,4 +5,4 @@ export { usePacksStore } from "./packsStore";
|
|||||||
export { useMessageStore } from "./messageStore";
|
export { useMessageStore } from "./messageStore";
|
||||||
export { useSubscriptionStore } from "./subscriptionStore";
|
export { useSubscriptionStore } from "./subscriptionStore";
|
||||||
export { useResponsesStore, groupResponses } from "./responsesStore";
|
export { useResponsesStore, groupResponses } from "./responsesStore";
|
||||||
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption } from "./responsesStore";
|
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption, DateSortOrder } from "./responsesStore";
|
||||||
@@ -147,15 +147,27 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
// Ideally we should have a separate query for count, but we'll just count in current batch
|
// Ideally we should have a separate query for count, but we'll just count in current batch
|
||||||
const newMatchesInBatch = sortedData.filter((m) => m.is_new).length;
|
const newMatchesInBatch = sortedData.filter((m) => m.is_new).length;
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
matches: refresh ? sortedData : [...state.matches, ...sortedData],
|
// Deduplicate matches by ID when appending new pages
|
||||||
|
const mergedMatches = refresh
|
||||||
|
? sortedData
|
||||||
|
: [...state.matches, ...sortedData].reduce<Match[]>((acc, match) => {
|
||||||
|
if (!acc.some((m: Match) => m.id === match.id)) {
|
||||||
|
acc.push(match);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
matches: mergedMatches,
|
||||||
newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch,
|
newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch,
|
||||||
totalCount: refresh ? totalCount : state.totalCount,
|
totalCount: refresh ? totalCount : state.totalCount,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
page: currentPage + 1,
|
page: currentPage + 1,
|
||||||
hasMore: matches.length === BATCH_SIZE
|
hasMore: matches.length === BATCH_SIZE
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching matches:", err);
|
console.error("Error fetching matches:", err);
|
||||||
set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false });
|
set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false });
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface UpdateResponseResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type GroupByOption = "pack" | "date" | "answer";
|
export type GroupByOption = "pack" | "date" | "answer";
|
||||||
|
export type DateSortOrder = "newest" | "oldest";
|
||||||
|
|
||||||
const BATCH_SIZE = 20;
|
const BATCH_SIZE = 20;
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ interface ResponsesState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
groupBy: GroupByOption;
|
groupBy: GroupByOption;
|
||||||
|
dateSortOrder: DateSortOrder;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
page: number;
|
page: number;
|
||||||
totalCount: number | null;
|
totalCount: number | null;
|
||||||
@@ -50,6 +52,8 @@ interface ResponsesState {
|
|||||||
confirmDelete?: boolean
|
confirmDelete?: boolean
|
||||||
) => Promise<UpdateResponseResult>;
|
) => Promise<UpdateResponseResult>;
|
||||||
setGroupBy: (groupBy: GroupByOption) => void;
|
setGroupBy: (groupBy: GroupByOption) => void;
|
||||||
|
setDateSortOrder: (order: DateSortOrder) => void;
|
||||||
|
toggleDateSortOrder: () => void;
|
||||||
clearResponses: () => void;
|
clearResponses: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +61,8 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
|||||||
responses: [],
|
responses: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
groupBy: "pack",
|
groupBy: "date",
|
||||||
|
dateSortOrder: "newest",
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
page: 0,
|
page: 0,
|
||||||
totalCount: null,
|
totalCount: null,
|
||||||
@@ -270,15 +275,26 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
|||||||
set({ groupBy });
|
set({ groupBy });
|
||||||
},
|
},
|
||||||
|
|
||||||
clearResponses: () => {
|
setDateSortOrder: (order: DateSortOrder) => {
|
||||||
set({ responses: [], isLoading: false, groupBy: "pack", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
|
set({ dateSortOrder: order });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleDateSortOrder: () => {
|
||||||
|
set((state) => ({
|
||||||
|
dateSortOrder: state.dateSortOrder === "newest" ? "oldest" : "newest",
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
clearResponses: () => {
|
||||||
|
set({ responses: [], isLoading: false, groupBy: "date", dateSortOrder: "newest", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Helper function to group responses
|
// Helper function to group responses
|
||||||
export function groupResponses(
|
export function groupResponses(
|
||||||
responses: ResponseWithQuestion[],
|
responses: ResponseWithQuestion[],
|
||||||
groupBy: GroupByOption
|
groupBy: GroupByOption,
|
||||||
|
dateSortOrder: DateSortOrder = "newest"
|
||||||
): { title: string; data: ResponseWithQuestion[] }[] {
|
): { title: string; data: ResponseWithQuestion[] }[] {
|
||||||
if (groupBy === "pack") {
|
if (groupBy === "pack") {
|
||||||
// Group by pack name
|
// Group by pack name
|
||||||
@@ -307,9 +323,16 @@ export function groupResponses(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (groupBy === "date") {
|
if (groupBy === "date") {
|
||||||
|
// Sort responses by date based on sort order
|
||||||
|
const sortedResponses = [...responses].sort((a, b) => {
|
||||||
|
const dateA = new Date(a.created_at).getTime();
|
||||||
|
const dateB = new Date(b.created_at).getTime();
|
||||||
|
return dateSortOrder === "newest" ? dateB - dateA : dateA - dateB;
|
||||||
|
});
|
||||||
|
|
||||||
// Group by date (day)
|
// Group by date (day)
|
||||||
const groups = new Map<string, ResponseWithQuestion[]>();
|
const groups = new Map<string, ResponseWithQuestion[]>();
|
||||||
responses.forEach((r) => {
|
sortedResponses.forEach((r) => {
|
||||||
const date = new Date(r.created_at).toLocaleDateString("en-US", {
|
const date = new Date(r.created_at).toLocaleDateString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
-- Filter individual questions by user's max_intensity preference
|
||||||
|
-- This fixes a bug where users would see intense questions despite having a gentle comfort zone
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
|
||||||
|
RETURNS TABLE(
|
||||||
|
id UUID,
|
||||||
|
text TEXT,
|
||||||
|
partner_text TEXT,
|
||||||
|
is_two_part BOOLEAN,
|
||||||
|
pack_id UUID,
|
||||||
|
intensity INTEGER,
|
||||||
|
partner_answered BOOLEAN,
|
||||||
|
allowed_couple_genders TEXT[],
|
||||||
|
target_user_genders TEXT[]
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id UUID;
|
||||||
|
v_couple_id UUID;
|
||||||
|
v_partner_id UUID;
|
||||||
|
v_user_gender TEXT;
|
||||||
|
v_partner_gender TEXT;
|
||||||
|
v_couple_type TEXT;
|
||||||
|
v_has_premium BOOLEAN;
|
||||||
|
v_gap_threshold INTEGER;
|
||||||
|
v_gap_count INTEGER;
|
||||||
|
v_user_max_intensity INTEGER;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
|
||||||
|
SELECT p.couple_id, p.gender, COALESCE(p.max_intensity, 5)
|
||||||
|
INTO v_couple_id, v_user_gender, v_user_max_intensity
|
||||||
|
FROM public.profiles p
|
||||||
|
WHERE p.id = v_user_id;
|
||||||
|
|
||||||
|
IF v_couple_id IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT p.id, p.gender INTO v_partner_id, v_partner_gender
|
||||||
|
FROM public.profiles p
|
||||||
|
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- Check answer gap threshold (only when not browsing a specific pack)
|
||||||
|
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL THEN
|
||||||
|
-- Get threshold from app_config
|
||||||
|
SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_gap_threshold
|
||||||
|
FROM app_config ac
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- If threshold > 0, check the gap
|
||||||
|
IF v_gap_threshold > 0 THEN
|
||||||
|
-- Count questions in ENABLED packs where user answered but partner hasn't
|
||||||
|
WITH enabled_packs AS (
|
||||||
|
SELECT cp.pack_id
|
||||||
|
FROM couple_packs cp
|
||||||
|
WHERE cp.couple_id = v_couple_id AND cp.enabled = true
|
||||||
|
)
|
||||||
|
SELECT COUNT(*)::integer INTO v_gap_count
|
||||||
|
FROM responses r
|
||||||
|
INNER JOIN questions q ON q.id = r.question_id
|
||||||
|
WHERE r.user_id = v_user_id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND EXISTS (SELECT 1 FROM enabled_packs)
|
||||||
|
AND q.pack_id IN (SELECT ep.pack_id FROM enabled_packs ep)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM responses pr
|
||||||
|
WHERE pr.question_id = r.question_id
|
||||||
|
AND pr.user_id = v_partner_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- If over threshold, return empty (old apps see "All caught up!")
|
||||||
|
IF v_gap_count >= v_gap_threshold THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN
|
||||||
|
IF v_user_gender < v_partner_gender THEN
|
||||||
|
v_couple_type := v_user_gender || '+' || v_partner_gender;
|
||||||
|
ELSE
|
||||||
|
v_couple_type := v_partner_gender || '+' || v_user_gender;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_has_premium := public.has_premium_access(v_user_id);
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH user_config_exists AS (
|
||||||
|
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
|
||||||
|
),
|
||||||
|
active_packs AS (
|
||||||
|
SELECT target_pack_id AS pack_id
|
||||||
|
WHERE target_pack_id IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM public.question_packs qp
|
||||||
|
WHERE qp.id = target_pack_id
|
||||||
|
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
|
||||||
|
)
|
||||||
|
UNION ALL
|
||||||
|
SELECT cp.pack_id FROM public.couple_packs cp
|
||||||
|
INNER JOIN public.question_packs qp ON qp.id = cp.pack_id
|
||||||
|
WHERE target_pack_id IS NULL
|
||||||
|
AND cp.couple_id = v_couple_id
|
||||||
|
AND cp.enabled = TRUE
|
||||||
|
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
|
||||||
|
AND EXISTS (SELECT 1 FROM user_config_exists)
|
||||||
|
UNION ALL
|
||||||
|
SELECT p.id AS pack_id FROM public.question_packs p
|
||||||
|
WHERE target_pack_id IS NULL
|
||||||
|
AND p.is_public = TRUE
|
||||||
|
AND (p.is_premium = FALSE OR v_has_premium = TRUE)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
q.id,
|
||||||
|
CASE
|
||||||
|
WHEN q.partner_text IS NOT NULL AND EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND r.user_id != v_user_id
|
||||||
|
) THEN q.partner_text
|
||||||
|
ELSE q.text
|
||||||
|
END as text,
|
||||||
|
q.partner_text,
|
||||||
|
q.partner_text IS NOT NULL as is_two_part,
|
||||||
|
q.pack_id,
|
||||||
|
q.intensity,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND r.user_id != v_user_id
|
||||||
|
) AS partner_answered,
|
||||||
|
q.allowed_couple_genders,
|
||||||
|
q.target_user_genders
|
||||||
|
FROM public.questions q
|
||||||
|
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.user_id = v_user_id
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
q.allowed_couple_genders IS NULL
|
||||||
|
OR v_couple_type IS NULL
|
||||||
|
OR v_couple_type = ANY(q.allowed_couple_genders)
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
q.target_user_genders IS NULL
|
||||||
|
OR v_user_gender = ANY(q.target_user_genders)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND r.user_id != v_user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
-- Filter by user's max_intensity preference
|
||||||
|
AND (
|
||||||
|
q.intensity IS NULL
|
||||||
|
OR q.intensity <= v_user_max_intensity
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user filtered by their intensity preference. When user is too far ahead of partner (answer gap threshold exceeded), returns empty.';
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
-- Dares Feature Migration
|
||||||
|
-- Creates dare_packs, dares, sent_dares, dare_messages tables
|
||||||
|
-- with RLS policies, triggers, and stats functions
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- CUSTOM TYPES
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TYPE dare_status AS ENUM (
|
||||||
|
'pending', -- Sent, waiting for recipient to accept/decline
|
||||||
|
'active', -- Recipient accepted, timer started (if applicable)
|
||||||
|
'completed', -- Sender marked as complete
|
||||||
|
'expired', -- Time ran out (only if had deadline)
|
||||||
|
'declined', -- Recipient declined
|
||||||
|
'cancelled' -- Sender cancelled before completion
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TABLES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Dare packs table (mirrors question_packs structure)
|
||||||
|
CREATE TABLE dare_packs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
icon TEXT,
|
||||||
|
is_premium BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
is_public BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
is_explicit BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
category_id UUID REFERENCES categories(id) ON DELETE SET NULL,
|
||||||
|
min_intensity INTEGER,
|
||||||
|
max_intensity INTEGER,
|
||||||
|
avg_intensity NUMERIC(3,2),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE dare_packs IS 'Collections of dares organized by theme or intensity level';
|
||||||
|
COMMENT ON COLUMN dare_packs.min_intensity IS 'Auto-calculated minimum intensity from dares in this pack';
|
||||||
|
COMMENT ON COLUMN dare_packs.max_intensity IS 'Auto-calculated maximum intensity from dares in this pack';
|
||||||
|
COMMENT ON COLUMN dare_packs.avg_intensity IS 'Auto-calculated average intensity from dares in this pack';
|
||||||
|
|
||||||
|
-- Create indexes for dare_packs
|
||||||
|
CREATE INDEX idx_dare_packs_sort_order ON dare_packs(sort_order);
|
||||||
|
CREATE INDEX idx_dare_packs_visibility ON dare_packs(is_public, is_premium);
|
||||||
|
CREATE INDEX idx_dare_packs_category ON dare_packs(category_id);
|
||||||
|
|
||||||
|
-- Dares table (individual dare content)
|
||||||
|
CREATE TABLE dares (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
pack_id UUID NOT NULL REFERENCES dare_packs(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
intensity INTEGER NOT NULL DEFAULT 1 CHECK (intensity >= 1 AND intensity <= 5),
|
||||||
|
suggested_duration_hours INTEGER,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE dares IS 'Individual dares within dare packs';
|
||||||
|
COMMENT ON COLUMN dares.intensity IS 'Difficulty/spiciness level from 1 (mild) to 5 (intense)';
|
||||||
|
COMMENT ON COLUMN dares.suggested_duration_hours IS 'Suggested time to complete the dare in hours (null = no suggestion)';
|
||||||
|
|
||||||
|
-- Create indexes for dares
|
||||||
|
CREATE INDEX idx_dares_pack_id ON dares(pack_id);
|
||||||
|
CREATE INDEX idx_dares_intensity ON dares(intensity);
|
||||||
|
|
||||||
|
-- Sent dares table (tracks dare instances between partners)
|
||||||
|
CREATE TABLE sent_dares (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
|
||||||
|
dare_id UUID REFERENCES dares(id) ON DELETE SET NULL,
|
||||||
|
custom_dare_text TEXT,
|
||||||
|
custom_dare_intensity INTEGER CHECK (custom_dare_intensity IS NULL OR (custom_dare_intensity >= 1 AND custom_dare_intensity <= 5)),
|
||||||
|
sender_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
recipient_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
status dare_status NOT NULL DEFAULT 'pending',
|
||||||
|
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
accepted_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
sender_notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT dare_or_custom CHECK (dare_id IS NOT NULL OR custom_dare_text IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE sent_dares IS 'Dare instances sent between partners in a couple';
|
||||||
|
COMMENT ON COLUMN sent_dares.dare_id IS 'Reference to the dare from a pack (null for custom dares)';
|
||||||
|
COMMENT ON COLUMN sent_dares.custom_dare_text IS 'Text for custom dares created by premium users';
|
||||||
|
COMMENT ON COLUMN sent_dares.custom_dare_intensity IS 'Intensity for custom dares (1-5)';
|
||||||
|
COMMENT ON COLUMN sent_dares.expires_at IS 'Deadline for completing the dare (null = no time limit)';
|
||||||
|
COMMENT ON COLUMN sent_dares.sender_notes IS 'Optional message from sender when sending the dare';
|
||||||
|
|
||||||
|
-- Create indexes for sent_dares
|
||||||
|
CREATE INDEX idx_sent_dares_couple_id ON sent_dares(couple_id);
|
||||||
|
CREATE INDEX idx_sent_dares_sender_id ON sent_dares(sender_id);
|
||||||
|
CREATE INDEX idx_sent_dares_recipient_id ON sent_dares(recipient_id);
|
||||||
|
CREATE INDEX idx_sent_dares_status ON sent_dares(status);
|
||||||
|
CREATE INDEX idx_sent_dares_expires_at ON sent_dares(expires_at) WHERE expires_at IS NOT NULL AND status = 'active';
|
||||||
|
|
||||||
|
-- Dare messages table (chat for each dare)
|
||||||
|
CREATE TABLE dare_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
sent_dare_id UUID NOT NULL REFERENCES sent_dares(id) ON DELETE CASCADE,
|
||||||
|
sender_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
read_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE dare_messages IS 'Chat messages associated with each sent dare';
|
||||||
|
|
||||||
|
-- Create indexes for dare_messages
|
||||||
|
CREATE INDEX idx_dare_messages_sent_dare_id ON dare_messages(sent_dare_id);
|
||||||
|
CREATE INDEX idx_dare_messages_created_at ON dare_messages(sent_dare_id, created_at);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TRIGGERS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Trigger function to auto-calculate dare pack intensity stats
|
||||||
|
CREATE OR REPLACE FUNCTION update_dare_pack_intensity_stats()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
target_pack_id UUID;
|
||||||
|
BEGIN
|
||||||
|
-- Get the pack_id to update
|
||||||
|
target_pack_id := COALESCE(NEW.pack_id, OLD.pack_id);
|
||||||
|
|
||||||
|
-- Update the dare pack stats
|
||||||
|
UPDATE dare_packs
|
||||||
|
SET
|
||||||
|
min_intensity = (SELECT MIN(intensity) FROM dares WHERE pack_id = target_pack_id),
|
||||||
|
max_intensity = (SELECT MAX(intensity) FROM dares WHERE pack_id = target_pack_id),
|
||||||
|
avg_intensity = (SELECT ROUND(AVG(intensity)::numeric, 2) FROM dares WHERE pack_id = target_pack_id)
|
||||||
|
WHERE id = target_pack_id;
|
||||||
|
|
||||||
|
RETURN COALESCE(NEW, OLD);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Create trigger on dares table
|
||||||
|
CREATE TRIGGER dare_intensity_stats_trigger
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON dares
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_dare_pack_intensity_stats();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STATS FUNCTIONS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Function to get dare stats for a specific user
|
||||||
|
CREATE OR REPLACE FUNCTION get_user_dare_stats(p_user_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
dares_sent_count BIGINT,
|
||||||
|
dares_received_count BIGINT,
|
||||||
|
dares_completed_as_sender BIGINT,
|
||||||
|
dares_completed_as_recipient BIGINT
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE sender_id = p_user_id)::BIGINT,
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id)::BIGINT,
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE sender_id = p_user_id AND status = 'completed')::BIGINT,
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id AND status = 'completed')::BIGINT;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Function to get dare stats for a couple
|
||||||
|
CREATE OR REPLACE FUNCTION get_couple_dare_stats(p_couple_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
total_dares_sent BIGINT,
|
||||||
|
total_dares_completed BIGINT,
|
||||||
|
total_dares_active BIGINT
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id)::BIGINT,
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'completed')::BIGINT,
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'active')::BIGINT;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- ROW LEVEL SECURITY
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Enable RLS on all tables
|
||||||
|
ALTER TABLE dare_packs ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE dares ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE sent_dares ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE dare_messages ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- -------------------------
|
||||||
|
-- dare_packs policies
|
||||||
|
-- -------------------------
|
||||||
|
|
||||||
|
-- Anyone can view public dare packs
|
||||||
|
CREATE POLICY "Public dare packs are viewable by everyone"
|
||||||
|
ON dare_packs FOR SELECT
|
||||||
|
USING (is_public = true);
|
||||||
|
|
||||||
|
-- Admins can view all dare packs
|
||||||
|
CREATE POLICY "Admins can view all dare packs"
|
||||||
|
ON dare_packs FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can insert dare packs
|
||||||
|
CREATE POLICY "Admins can insert dare packs"
|
||||||
|
ON dare_packs FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can update dare packs
|
||||||
|
CREATE POLICY "Admins can update dare packs"
|
||||||
|
ON dare_packs FOR UPDATE
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can delete dare packs
|
||||||
|
CREATE POLICY "Admins can delete dare packs"
|
||||||
|
ON dare_packs FOR DELETE
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------
|
||||||
|
-- dares policies
|
||||||
|
-- -------------------------
|
||||||
|
|
||||||
|
-- Users can view dares in accessible packs (public and free, or public and user is premium)
|
||||||
|
CREATE POLICY "Users can view dares in accessible packs"
|
||||||
|
ON dares FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM dare_packs dp
|
||||||
|
WHERE dp.id = dares.pack_id
|
||||||
|
AND dp.is_public = true
|
||||||
|
AND (
|
||||||
|
dp.is_premium = false
|
||||||
|
OR EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND is_premium = true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can view all dares
|
||||||
|
CREATE POLICY "Admins can view all dares"
|
||||||
|
ON dares FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can insert dares
|
||||||
|
CREATE POLICY "Admins can insert dares"
|
||||||
|
ON dares FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can update dares
|
||||||
|
CREATE POLICY "Admins can update dares"
|
||||||
|
ON dares FOR UPDATE
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can delete dares
|
||||||
|
CREATE POLICY "Admins can delete dares"
|
||||||
|
ON dares FOR DELETE
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------
|
||||||
|
-- sent_dares policies
|
||||||
|
-- -------------------------
|
||||||
|
|
||||||
|
-- Users can view sent dares in their couple
|
||||||
|
CREATE POLICY "Users can view their couple's sent dares"
|
||||||
|
ON sent_dares FOR SELECT
|
||||||
|
USING (couple_id = get_auth_user_couple_id());
|
||||||
|
|
||||||
|
-- Users can insert sent dares (sender must be the authenticated user)
|
||||||
|
CREATE POLICY "Users can send dares to their partner"
|
||||||
|
ON sent_dares FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
sender_id = auth.uid()
|
||||||
|
AND couple_id = get_auth_user_couple_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users can update sent dares in their couple
|
||||||
|
CREATE POLICY "Users can update their couple's sent dares"
|
||||||
|
ON sent_dares FOR UPDATE
|
||||||
|
USING (couple_id = get_auth_user_couple_id());
|
||||||
|
|
||||||
|
-- Admins can view all sent dares
|
||||||
|
CREATE POLICY "Admins can view all sent dares"
|
||||||
|
ON sent_dares FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------
|
||||||
|
-- dare_messages policies
|
||||||
|
-- -------------------------
|
||||||
|
|
||||||
|
-- Users can view messages for their couple's dares
|
||||||
|
CREATE POLICY "Users can view messages for their dares"
|
||||||
|
ON dare_messages FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM sent_dares sd
|
||||||
|
WHERE sd.id = dare_messages.sent_dare_id
|
||||||
|
AND sd.couple_id = get_auth_user_couple_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users can insert messages for their couple's dares
|
||||||
|
CREATE POLICY "Users can send messages for their dares"
|
||||||
|
ON dare_messages FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
sender_id = auth.uid()
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM sent_dares sd
|
||||||
|
WHERE sd.id = dare_messages.sent_dare_id
|
||||||
|
AND sd.couple_id = get_auth_user_couple_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users can update messages in their dares (for read_at)
|
||||||
|
CREATE POLICY "Users can update message read status"
|
||||||
|
ON dare_messages FOR UPDATE
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM sent_dares sd
|
||||||
|
WHERE sd.id = dare_messages.sent_dare_id
|
||||||
|
AND sd.couple_id = get_auth_user_couple_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can view all dare messages
|
||||||
|
CREATE POLICY "Admins can view all dare messages"
|
||||||
|
ON dare_messages FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- REALTIME
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Add tables to realtime publication for live updates
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE sent_dares;
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE dare_messages;
|
||||||
@@ -4,4 +4,5 @@ NEXT_PUBLIC_POSTHOG_KEY=phc_your_project_key_here
|
|||||||
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
||||||
|
|
||||||
# Supabase
|
# Supabase
|
||||||
|
# Production: https://ckjcrkjpmhqhiucifukx.supabase.co
|
||||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||||
@@ -6,9 +6,11 @@ WORKDIR /app
|
|||||||
# Build args for Next.js public env vars (inlined at build time)
|
# Build args for Next.js public env vars (inlined at build time)
|
||||||
ARG NEXT_PUBLIC_POSTHOG_KEY
|
ARG NEXT_PUBLIC_POSTHOG_KEY
|
||||||
ARG NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
ARG NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
||||||
|
ARG NEXT_PUBLIC_SUPABASE_URL=https://ckjcrkjpmhqhiucifukx.supabase.co
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY
|
ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY
|
||||||
ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST
|
ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST
|
||||||
|
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ services:
|
|||||||
args:
|
args:
|
||||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||||
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-https://eu.i.posthog.com}
|
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-https://eu.i.posthog.com}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL:-https://ckjcrkjpmhqhiucifukx.supabase.co}
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- NEXT_TELEMETRY_DISABLED=1
|
- NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|||||||
@@ -0,0 +1,625 @@
|
|||||||
|
# Dares Feature - Architecture & Implementation Plan
|
||||||
|
|
||||||
|
> **Status**: Phase 1 Complete (Database + Admin Portal)
|
||||||
|
> **Created**: 2026-01-08
|
||||||
|
> **Updated**: 2026-01-08
|
||||||
|
> **Target**: Schema + Admin Portal (Phase 1 - DONE), Mobile App (Phase 2 - Later)
|
||||||
|
|
||||||
|
## Implementation Summary
|
||||||
|
|
||||||
|
### Completed (Phase 1)
|
||||||
|
|
||||||
|
| Component | Files | Status |
|
||||||
|
|-----------|-------|--------|
|
||||||
|
| Database Migration | `apps/supabase/migrations/20260109000000_create_dares_feature.sql` | Applied to prod |
|
||||||
|
| Dare Packs Page | `apps/admin/src/pages/content/DarePacksPage.tsx` | Complete |
|
||||||
|
| Dares Page | `apps/admin/src/pages/content/DaresPage.tsx` | Complete |
|
||||||
|
| Routes | `apps/admin/src/App.tsx` | Updated |
|
||||||
|
| Sidebar | `apps/admin/src/components/layout/Sidebar.tsx` | Updated |
|
||||||
|
|
||||||
|
### Database Tables Created
|
||||||
|
|
||||||
|
- `dare_packs` - Collections of dares (0 rows)
|
||||||
|
- `dares` - Individual dare content (0 rows)
|
||||||
|
- `sent_dares` - Dare instances between partners (0 rows)
|
||||||
|
- `dare_messages` - Chat messages for dares (0 rows)
|
||||||
|
- `dare_status` enum type with states: pending, active, completed, expired, declined, cancelled
|
||||||
|
|
||||||
|
### Admin Routes Added
|
||||||
|
|
||||||
|
- `/dare-packs` - List all dare packs
|
||||||
|
- `/dare-packs/:packId/dares` - List dares in a pack
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Dares feature allows users to send challenges/dares to their partner from curated packs. Partners can accept or decline dares, and the sender marks them as complete. A scoreboard tracks total completed dares per user/couple.
|
||||||
|
|
||||||
|
### Key Decisions
|
||||||
|
|
||||||
|
| Aspect | Decision |
|
||||||
|
|--------|----------|
|
||||||
|
| Dare Selection | User browses list, picks dare to send |
|
||||||
|
| Decline | Recipient can decline (no negative impact) |
|
||||||
|
| Cancel | Sender can cancel before completion |
|
||||||
|
| Timeframes | Pick from preset list + "no time limit" option |
|
||||||
|
| Scoreboard | Simple count of completed dares |
|
||||||
|
| Custom Dares | Premium users can create their own |
|
||||||
|
| Gender Filtering | Not needed (user chooses which dare to send) |
|
||||||
|
| Premium Model | Same as question packs (free/premium packs) |
|
||||||
|
| Chat | Auto-create chat thread when dare is sent |
|
||||||
|
| Completion | Sender marks dare as complete |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### New Enum Types
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TYPE dare_status AS ENUM (
|
||||||
|
'pending', -- Sent, waiting for recipient to accept/decline
|
||||||
|
'active', -- Recipient accepted, timer started (if applicable)
|
||||||
|
'completed', -- Sender marked as complete
|
||||||
|
'expired', -- Time ran out (only if had deadline)
|
||||||
|
'declined', -- Recipient declined
|
||||||
|
'cancelled' -- Sender cancelled before completion
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table: `dare_packs`
|
||||||
|
|
||||||
|
Mirrors `question_packs` structure for consistency.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Default | Description |
|
||||||
|
|--------|------|----------|---------|-------------|
|
||||||
|
| `id` | UUID | NO | `gen_random_uuid()` | Primary key |
|
||||||
|
| `name` | TEXT | NO | | Pack name |
|
||||||
|
| `description` | TEXT | YES | | Pack description |
|
||||||
|
| `icon` | TEXT | YES | | Emoji icon |
|
||||||
|
| `is_premium` | BOOLEAN | NO | `false` | Requires premium access |
|
||||||
|
| `is_public` | BOOLEAN | NO | `true` | Visible to users |
|
||||||
|
| `is_explicit` | BOOLEAN | NO | `false` | Contains explicit content |
|
||||||
|
| `sort_order` | INTEGER | NO | `0` | Display ordering |
|
||||||
|
| `category_id` | UUID | YES | | FK to `categories` (optional) |
|
||||||
|
| `min_intensity` | INTEGER | YES | | Auto-calculated from dares |
|
||||||
|
| `max_intensity` | INTEGER | YES | | Auto-calculated from dares |
|
||||||
|
| `avg_intensity` | NUMERIC(3,2) | YES | | Auto-calculated from dares |
|
||||||
|
| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- Primary key on `id`
|
||||||
|
- Index on `sort_order` for ordering
|
||||||
|
- Index on `is_public, is_premium` for filtering
|
||||||
|
|
||||||
|
### Table: `dares`
|
||||||
|
|
||||||
|
Individual dare content within packs.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Default | Description |
|
||||||
|
|--------|------|----------|---------|-------------|
|
||||||
|
| `id` | UUID | NO | `gen_random_uuid()` | Primary key |
|
||||||
|
| `pack_id` | UUID | NO | | FK to `dare_packs` (CASCADE delete) |
|
||||||
|
| `text` | TEXT | NO | | Dare description/instruction |
|
||||||
|
| `intensity` | INTEGER | NO | `1` | 1-5 scale (CHECK constraint) |
|
||||||
|
| `suggested_duration_hours` | INTEGER | YES | | Suggested timeframe in hours |
|
||||||
|
| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp |
|
||||||
|
|
||||||
|
**Constraints:**
|
||||||
|
- `CHECK (intensity >= 1 AND intensity <= 5)`
|
||||||
|
- Foreign key to `dare_packs` with `ON DELETE CASCADE`
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- Primary key on `id`
|
||||||
|
- Index on `pack_id` for lookups
|
||||||
|
|
||||||
|
### Table: `sent_dares`
|
||||||
|
|
||||||
|
Tracks dare instances sent between partners.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Default | Description |
|
||||||
|
|--------|------|----------|---------|-------------|
|
||||||
|
| `id` | UUID | NO | `gen_random_uuid()` | Primary key |
|
||||||
|
| `couple_id` | UUID | NO | | FK to `couples` (CASCADE delete) |
|
||||||
|
| `dare_id` | UUID | YES | | FK to `dares` (null for custom dares) |
|
||||||
|
| `custom_dare_text` | TEXT | YES | | Text for custom dares |
|
||||||
|
| `custom_dare_intensity` | INTEGER | YES | | Intensity for custom dares (1-5) |
|
||||||
|
| `sender_id` | UUID | NO | | FK to `profiles` |
|
||||||
|
| `recipient_id` | UUID | NO | | FK to `profiles` |
|
||||||
|
| `status` | `dare_status` | NO | `'pending'` | Current status |
|
||||||
|
| `sent_at` | TIMESTAMPTZ | NO | `now()` | When dare was sent |
|
||||||
|
| `accepted_at` | TIMESTAMPTZ | YES | | When recipient accepted |
|
||||||
|
| `expires_at` | TIMESTAMPTZ | YES | | Deadline (null = no limit) |
|
||||||
|
| `completed_at` | TIMESTAMPTZ | YES | | When marked complete |
|
||||||
|
| `sender_notes` | TEXT | YES | | Optional message from sender |
|
||||||
|
| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp |
|
||||||
|
|
||||||
|
**Constraints:**
|
||||||
|
- `CHECK (custom_dare_intensity IS NULL OR (custom_dare_intensity >= 1 AND custom_dare_intensity <= 5))`
|
||||||
|
- `CHECK ((dare_id IS NOT NULL) OR (custom_dare_text IS NOT NULL))` -- Must have either a dare_id or custom text
|
||||||
|
- Foreign keys with appropriate cascade behavior
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- Primary key on `id`
|
||||||
|
- Index on `couple_id` for couple lookups
|
||||||
|
- Index on `sender_id` for sender stats
|
||||||
|
- Index on `recipient_id` for recipient stats
|
||||||
|
- Index on `status` for filtering active dares
|
||||||
|
- Index on `expires_at` for expiry job
|
||||||
|
|
||||||
|
### Table: `dare_messages`
|
||||||
|
|
||||||
|
Chat messages for each dare instance.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Default | Description |
|
||||||
|
|--------|------|----------|---------|-------------|
|
||||||
|
| `id` | UUID | NO | `gen_random_uuid()` | Primary key |
|
||||||
|
| `sent_dare_id` | UUID | NO | | FK to `sent_dares` (CASCADE delete) |
|
||||||
|
| `sender_id` | UUID | NO | | FK to `profiles` |
|
||||||
|
| `content` | TEXT | NO | | Message content |
|
||||||
|
| `read_at` | TIMESTAMPTZ | YES | | When recipient read message |
|
||||||
|
| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- Primary key on `id`
|
||||||
|
- Index on `sent_dare_id` for message lookups
|
||||||
|
- Index on `created_at` for ordering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Functions & Triggers
|
||||||
|
|
||||||
|
### 1. Intensity Stats Trigger
|
||||||
|
|
||||||
|
Auto-calculate `min_intensity`, `max_intensity`, `avg_intensity` on `dare_packs` when dares are inserted/updated/deleted.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION update_dare_pack_intensity_stats()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE dare_packs
|
||||||
|
SET
|
||||||
|
min_intensity = (SELECT MIN(intensity) FROM dares WHERE pack_id = COALESCE(NEW.pack_id, OLD.pack_id)),
|
||||||
|
max_intensity = (SELECT MAX(intensity) FROM dares WHERE pack_id = COALESCE(NEW.pack_id, OLD.pack_id)),
|
||||||
|
avg_intensity = (SELECT ROUND(AVG(intensity)::numeric, 2) FROM dares WHERE pack_id = COALESCE(NEW.pack_id, OLD.pack_id))
|
||||||
|
WHERE id = COALESCE(NEW.pack_id, OLD.pack_id);
|
||||||
|
RETURN COALESCE(NEW, OLD);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER dare_intensity_stats_trigger
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON dares
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_dare_pack_intensity_stats();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. User Dare Stats Function
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION get_user_dare_stats(p_user_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
dares_sent_count BIGINT,
|
||||||
|
dares_received_count BIGINT,
|
||||||
|
dares_completed_as_sender BIGINT,
|
||||||
|
dares_completed_as_recipient BIGINT
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE sender_id = p_user_id),
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id),
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE sender_id = p_user_id AND status = 'completed'),
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id AND status = 'completed');
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Couple Dare Stats Function
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION get_couple_dare_stats(p_couple_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
total_dares_sent BIGINT,
|
||||||
|
total_dares_completed BIGINT,
|
||||||
|
total_dares_active BIGINT
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id),
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'completed'),
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'active');
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RLS Policies
|
||||||
|
|
||||||
|
### `dare_packs`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Anyone can view public packs
|
||||||
|
CREATE POLICY "Public dare packs are viewable by everyone"
|
||||||
|
ON dare_packs FOR SELECT
|
||||||
|
USING (is_public = true);
|
||||||
|
|
||||||
|
-- Admins can do everything
|
||||||
|
CREATE POLICY "Admins can manage dare packs"
|
||||||
|
ON dare_packs FOR ALL
|
||||||
|
USING (is_admin());
|
||||||
|
```
|
||||||
|
|
||||||
|
### `dares`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- View dares if pack is accessible (public, or premium pack + premium user)
|
||||||
|
CREATE POLICY "Users can view dares in accessible packs"
|
||||||
|
ON dares FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM dare_packs dp
|
||||||
|
WHERE dp.id = dares.pack_id
|
||||||
|
AND dp.is_public = true
|
||||||
|
AND (
|
||||||
|
dp.is_premium = false
|
||||||
|
OR EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND is_premium = true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can do everything
|
||||||
|
CREATE POLICY "Admins can manage dares"
|
||||||
|
ON dares FOR ALL
|
||||||
|
USING (is_admin());
|
||||||
|
```
|
||||||
|
|
||||||
|
### `sent_dares`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Users can view sent dares in their couple
|
||||||
|
CREATE POLICY "Users can view their couple's sent dares"
|
||||||
|
ON sent_dares FOR SELECT
|
||||||
|
USING (couple_id = get_auth_user_couple_id());
|
||||||
|
|
||||||
|
-- Users can update sent dares in their couple (for status changes)
|
||||||
|
CREATE POLICY "Users can update their couple's sent dares"
|
||||||
|
ON sent_dares FOR UPDATE
|
||||||
|
USING (couple_id = get_auth_user_couple_id());
|
||||||
|
|
||||||
|
-- Insert via edge function only (service role)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `dare_messages`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Users can view messages for their couple's dares
|
||||||
|
CREATE POLICY "Users can view messages for their dares"
|
||||||
|
ON dare_messages FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM sent_dares sd
|
||||||
|
WHERE sd.id = dare_messages.sent_dare_id
|
||||||
|
AND sd.couple_id = get_auth_user_couple_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users can insert messages for their couple's dares
|
||||||
|
CREATE POLICY "Users can send messages for their dares"
|
||||||
|
ON dare_messages FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
sender_id = auth.uid()
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM sent_dares sd
|
||||||
|
WHERE sd.id = dare_messages.sent_dare_id
|
||||||
|
AND sd.couple_id = get_auth_user_couple_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users can update their own messages (for read_at)
|
||||||
|
CREATE POLICY "Users can update read status"
|
||||||
|
ON dare_messages FOR UPDATE
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM sent_dares sd
|
||||||
|
WHERE sd.id = dare_messages.sent_dare_id
|
||||||
|
AND sd.couple_id = get_auth_user_couple_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Preset Duration Options
|
||||||
|
|
||||||
|
App constants (not in database, but used in app/admin):
|
||||||
|
|
||||||
|
| Label | Value (hours) | Display |
|
||||||
|
|-------|---------------|---------|
|
||||||
|
| 1 hour | `1` | "1 hour" |
|
||||||
|
| 6 hours | `6` | "6 hours" |
|
||||||
|
| 12 hours | `12` | "12 hours" |
|
||||||
|
| 24 hours | `24` | "24 hours" |
|
||||||
|
| 3 days | `72` | "3 days" |
|
||||||
|
| 1 week | `168` | "1 week" |
|
||||||
|
| No time limit | `null` | "No time limit" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Functions (Future - App Phase)
|
||||||
|
|
||||||
|
### `send-dare`
|
||||||
|
|
||||||
|
Send a dare to partner.
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```typescript
|
||||||
|
interface SendDareInput {
|
||||||
|
dare_id?: string; // null for custom dare
|
||||||
|
custom_dare_text?: string; // required if dare_id is null
|
||||||
|
custom_dare_intensity?: number; // 1-5, required if custom
|
||||||
|
duration_hours?: number; // null = no limit
|
||||||
|
sender_notes?: string; // optional message
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
1. Validate user is in a couple
|
||||||
|
2. Get partner ID from couple
|
||||||
|
3. If custom dare, validate user is premium
|
||||||
|
4. Create `sent_dares` record with status `'pending'`
|
||||||
|
5. Calculate `expires_at` if duration provided
|
||||||
|
6. Trigger push notification to recipient
|
||||||
|
|
||||||
|
### `respond-dare`
|
||||||
|
|
||||||
|
Accept or decline a dare.
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```typescript
|
||||||
|
interface RespondDareInput {
|
||||||
|
sent_dare_id: string;
|
||||||
|
action: 'accept' | 'decline';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
1. Validate user is recipient of the dare
|
||||||
|
2. Validate status is `'pending'`
|
||||||
|
3. If accept: set status to `'active'`, set `accepted_at`
|
||||||
|
4. If decline: set status to `'declined'`
|
||||||
|
5. Trigger push notification to sender
|
||||||
|
|
||||||
|
### `complete-dare`
|
||||||
|
|
||||||
|
Sender marks dare as complete.
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```typescript
|
||||||
|
interface CompleteDareInput {
|
||||||
|
sent_dare_id: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
1. Validate user is sender of the dare
|
||||||
|
2. Validate status is `'active'`
|
||||||
|
3. Set status to `'completed'`, set `completed_at`
|
||||||
|
4. Trigger push notification to recipient
|
||||||
|
|
||||||
|
### `cancel-dare`
|
||||||
|
|
||||||
|
Sender cancels a dare.
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```typescript
|
||||||
|
interface CancelDareInput {
|
||||||
|
sent_dare_id: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
1. Validate user is sender
|
||||||
|
2. Validate status is `'pending'` or `'active'`
|
||||||
|
3. Set status to `'cancelled'`
|
||||||
|
4. Trigger push notification to recipient
|
||||||
|
|
||||||
|
### Background Job: Expire Dares
|
||||||
|
|
||||||
|
Cron job (pg_cron or external) to expire overdue dares:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE sent_dares
|
||||||
|
SET status = 'expired'
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND expires_at IS NOT NULL
|
||||||
|
AND expires_at < NOW();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notifications (Future - App Phase)
|
||||||
|
|
||||||
|
| Event | Recipient | Title | Body |
|
||||||
|
|-------|-----------|-------|------|
|
||||||
|
| Dare sent | Partner | "New Dare!" | "{name} sent you a dare" |
|
||||||
|
| Dare accepted | Sender | "Dare Accepted" | "{name} accepted your dare" |
|
||||||
|
| Dare declined | Sender | "Dare Declined" | "{name} declined your dare" |
|
||||||
|
| Dare completed | Recipient | "Dare Complete!" | "Your dare was marked complete" |
|
||||||
|
| Dare cancelled | Recipient | "Dare Cancelled" | "{name} cancelled the dare" |
|
||||||
|
| Dare expiring (1hr) | Recipient | "Dare Expiring Soon" | "Your dare expires in 1 hour" |
|
||||||
|
| Dare expired | Both | "Dare Expired" | "The dare has expired" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Portal Implementation
|
||||||
|
|
||||||
|
### New Files Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/admin/src/
|
||||||
|
├── pages/
|
||||||
|
│ ├── DarePacksPage.tsx # List all dare packs
|
||||||
|
│ ├── DarePackDetailPage.tsx # Create/edit dare pack
|
||||||
|
│ ├── DaresPage.tsx # List dares in a pack
|
||||||
|
│ └── DareDetailPage.tsx # Create/edit individual dare
|
||||||
|
├── components/
|
||||||
|
│ └── IntensityIndicator.tsx # Shared intensity display (1-5)
|
||||||
|
└── App.tsx # Add new routes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routes to Add
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In App.tsx
|
||||||
|
<Route path="/dare-packs" element={<DarePacksPage />} />
|
||||||
|
<Route path="/dare-packs/new" element={<DarePackDetailPage />} />
|
||||||
|
<Route path="/dare-packs/:packId" element={<DarePackDetailPage />} />
|
||||||
|
<Route path="/dare-packs/:packId/dares" element={<DaresPage />} />
|
||||||
|
<Route path="/dare-packs/:packId/dares/new" element={<DareDetailPage />} />
|
||||||
|
<Route path="/dare-packs/:packId/dares/:dareId" element={<DareDetailPage />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sidebar Navigation Update
|
||||||
|
|
||||||
|
Add under "Content" section:
|
||||||
|
```
|
||||||
|
📦 Content
|
||||||
|
├── Question Packs
|
||||||
|
└── Dare Packs (new)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Page Specifications
|
||||||
|
|
||||||
|
#### DarePacksPage
|
||||||
|
|
||||||
|
- **Table columns**: Icon, Name, Description (truncated), Dares Count, Intensity Range, Premium, Public, Actions
|
||||||
|
- **Actions**: Edit, Delete, View Dares
|
||||||
|
- **Create button**: Top right
|
||||||
|
- **Empty state**: "No dare packs yet. Create your first pack!"
|
||||||
|
|
||||||
|
#### DarePackDetailPage
|
||||||
|
|
||||||
|
- **Form fields**:
|
||||||
|
- Name (text input, required)
|
||||||
|
- Description (textarea)
|
||||||
|
- Icon (emoji picker or text input)
|
||||||
|
- Category (dropdown, optional)
|
||||||
|
- Is Premium (toggle)
|
||||||
|
- Is Public (toggle)
|
||||||
|
- Is Explicit (toggle)
|
||||||
|
- Sort Order (number input)
|
||||||
|
- **Read-only display**: Intensity stats (min/max/avg)
|
||||||
|
- **Actions**: Save, Cancel, Delete (if editing)
|
||||||
|
|
||||||
|
#### DaresPage
|
||||||
|
|
||||||
|
- **Table columns**: Text (truncated), Intensity (visual indicator), Suggested Duration, Actions
|
||||||
|
- **Actions**: Edit, Delete
|
||||||
|
- **Create button**: Top right
|
||||||
|
- **Back link**: To dare pack detail
|
||||||
|
|
||||||
|
#### DareDetailPage
|
||||||
|
|
||||||
|
- **Form fields**:
|
||||||
|
- Text (textarea, required)
|
||||||
|
- Intensity (slider 1-5 with labels)
|
||||||
|
- Suggested Duration (dropdown with preset options + "None")
|
||||||
|
- **Actions**: Save, Cancel, Delete (if editing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Tasks
|
||||||
|
|
||||||
|
### Phase 1: Database Schema (Supabase)
|
||||||
|
|
||||||
|
- [ ] **1.1** Create migration: `dare_status` enum type
|
||||||
|
- [ ] **1.2** Create migration: `dare_packs` table
|
||||||
|
- [ ] **1.3** Create migration: `dares` table with intensity trigger
|
||||||
|
- [ ] **1.4** Create migration: `sent_dares` table
|
||||||
|
- [ ] **1.5** Create migration: `dare_messages` table
|
||||||
|
- [ ] **1.6** Create migration: RLS policies for all tables
|
||||||
|
- [ ] **1.7** Create migration: Stats functions (`get_user_dare_stats`, `get_couple_dare_stats`)
|
||||||
|
- [ ] **1.8** Create migration: Add tables to realtime publication
|
||||||
|
- [ ] **1.9** Test migrations in non-prod environment
|
||||||
|
- [ ] **1.10** Apply migrations to production
|
||||||
|
|
||||||
|
### Phase 2: Admin Portal
|
||||||
|
|
||||||
|
- [ ] **2.1** Create `IntensityIndicator` component (if not exists)
|
||||||
|
- [ ] **2.2** Create `DarePacksPage` - list view
|
||||||
|
- [ ] **2.3** Create `DarePackDetailPage` - create/edit form
|
||||||
|
- [ ] **2.4** Create `DaresPage` - list dares in pack
|
||||||
|
- [ ] **2.5** Create `DareDetailPage` - create/edit dare form
|
||||||
|
- [ ] **2.6** Add routes to `App.tsx`
|
||||||
|
- [ ] **2.7** Update sidebar navigation
|
||||||
|
- [ ] **2.8** Test full CRUD flow in admin portal
|
||||||
|
|
||||||
|
### Phase 3: Edge Functions (Future - App Phase)
|
||||||
|
|
||||||
|
- [ ] **3.1** Create `send-dare` edge function
|
||||||
|
- [ ] **3.2** Create `respond-dare` edge function
|
||||||
|
- [ ] **3.3** Create `complete-dare` edge function
|
||||||
|
- [ ] **3.4** Create `cancel-dare` edge function
|
||||||
|
- [ ] **3.5** Set up dare expiry cron job
|
||||||
|
- [ ] **3.6** Integrate with notification system
|
||||||
|
|
||||||
|
### Phase 4: Mobile App (Future)
|
||||||
|
|
||||||
|
- [ ] **4.1** Create dare packs browsing UI
|
||||||
|
- [ ] **4.2** Create dare selection/sending flow
|
||||||
|
- [ ] **4.3** Create incoming dare notification + response UI
|
||||||
|
- [ ] **4.4** Create active dare tracking UI
|
||||||
|
- [ ] **4.5** Create dare chat integration
|
||||||
|
- [ ] **4.6** Create scoreboard/stats display
|
||||||
|
- [ ] **4.7** Create custom dare creation (premium)
|
||||||
|
- [ ] **4.8** Add to Zustand stores
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Competitor Analysis Notes
|
||||||
|
|
||||||
|
Features observed in competitor apps (Desire, Kindu, Spicer, Lovewick):
|
||||||
|
|
||||||
|
| Feature | In Our Plan | Notes |
|
||||||
|
|---------|-------------|-------|
|
||||||
|
| Dare Packs/Categories | ✅ | Themed collections |
|
||||||
|
| Intensity Levels | ✅ | 1-5 scale, reuses user preference |
|
||||||
|
| Time Limits | ✅ | Preset options + no limit |
|
||||||
|
| Points/Scoring | ✅ Simple | Just count, not weighted points |
|
||||||
|
| Custom Dares | ✅ Premium | Users create their own |
|
||||||
|
| Decline Option | ✅ | No negative impact |
|
||||||
|
| Dare History | ✅ | Via `sent_dares` table |
|
||||||
|
| Chat per Dare | ✅ | `dare_messages` table |
|
||||||
|
| Proof/Verification | ❌ | Not in v1 (could add photo proof later) |
|
||||||
|
| Daily/Random Dares | ❌ | Not in v1 (could add "random dare" button) |
|
||||||
|
| Dare Rewards | ❌ | Not in v1 (could unlock content) |
|
||||||
|
| Streak Tracking | ❌ | Not in v1 (could add later) |
|
||||||
|
|
||||||
|
### Potential Future Enhancements
|
||||||
|
|
||||||
|
1. **Photo/Video Proof**: Allow recipient to submit proof of completion
|
||||||
|
2. **Random Dare Button**: "Surprise me" feature that picks a random dare
|
||||||
|
3. **Daily Dare Suggestions**: Push notification with suggested dare
|
||||||
|
4. **Streak Tracking**: Consecutive days/weeks with completed dares
|
||||||
|
5. **Achievements/Badges**: Unlock badges for milestones (10 dares, 50 dares, etc.)
|
||||||
|
6. **Couple Leaderboard**: Compare with other couples (opt-in)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions (Resolved)
|
||||||
|
|
||||||
|
| Question | Answer |
|
||||||
|
|----------|--------|
|
||||||
|
| Should dare packs use same `categories` table? | Yes, reuse existing categories |
|
||||||
|
| Can same dare be sent multiple times? | Yes, each is a new instance |
|
||||||
|
| Bulk import for dares in admin? | Not in v1, can add later |
|
||||||
|
| Migration target | Non-prod first, then prod |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Existing schema patterns: `apps/supabase/migrations/20251230000000_v1_initial_schema.sql`
|
||||||
|
- Question packs structure: `question_packs`, `questions` tables
|
||||||
|
- Match/messaging pattern: `matches`, `messages` tables
|
||||||
|
- Admin portal patterns: `apps/admin/src/pages/QuestionPacksPage.tsx`
|
||||||
|
Before Width: | Height: | Size: 755 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 613 KiB |
|
Before Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 646 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 863 KiB |
|
Before Width: | Height: | Size: 966 KiB |
|
Before Width: | Height: | Size: 780 KiB |
|
After Width: | Height: | Size: 979 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 703 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 825 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
@@ -45,6 +45,7 @@
|
|||||||
"openai": "^4.24.0",
|
"openai": "^4.24.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-icons": "^4.12.0",
|
||||||
"react-router-dom": "^6.21.0",
|
"react-router-dom": "^6.21.0",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"sonner": "^1.3.1",
|
"sonner": "^1.3.1",
|
||||||
@@ -183,6 +184,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -586,7 +588,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz",
|
||||||
"integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==",
|
"integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1",
|
"@babel/helper-plugin-utils": "^7.27.1",
|
||||||
"@babel/traverse": "^7.28.5"
|
"@babel/traverse": "^7.28.5"
|
||||||
@@ -603,7 +604,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz",
|
||||||
"integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==",
|
"integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -619,7 +619,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz",
|
||||||
"integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==",
|
"integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -635,7 +634,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz",
|
||||||
"integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==",
|
"integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1",
|
"@babel/helper-plugin-utils": "^7.27.1",
|
||||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
|
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
|
||||||
@@ -653,7 +651,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz",
|
||||||
"integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==",
|
"integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1",
|
"@babel/helper-plugin-utils": "^7.27.1",
|
||||||
"@babel/traverse": "^7.28.3"
|
"@babel/traverse": "^7.28.3"
|
||||||
@@ -754,7 +751,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
|
||||||
"integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==",
|
"integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
},
|
},
|
||||||
@@ -875,7 +871,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz",
|
||||||
"integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==",
|
"integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -1062,7 +1057,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
|
||||||
"integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==",
|
"integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-create-regexp-features-plugin": "^7.18.6",
|
"@babel/helper-create-regexp-features-plugin": "^7.18.6",
|
||||||
"@babel/helper-plugin-utils": "^7.18.6"
|
"@babel/helper-plugin-utils": "^7.18.6"
|
||||||
@@ -1128,7 +1122,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz",
|
||||||
"integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==",
|
"integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -1175,7 +1168,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz",
|
||||||
"integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==",
|
"integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-create-class-features-plugin": "^7.28.3",
|
"@babel/helper-create-class-features-plugin": "^7.28.3",
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
@@ -1244,7 +1236,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz",
|
||||||
"integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==",
|
"integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-create-regexp-features-plugin": "^7.27.1",
|
"@babel/helper-create-regexp-features-plugin": "^7.27.1",
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
@@ -1261,7 +1252,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz",
|
||||||
"integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==",
|
"integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -1277,7 +1267,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz",
|
||||||
"integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==",
|
"integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-create-regexp-features-plugin": "^7.27.1",
|
"@babel/helper-create-regexp-features-plugin": "^7.27.1",
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
@@ -1294,7 +1283,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz",
|
||||||
"integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==",
|
"integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -1310,7 +1298,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz",
|
||||||
"integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==",
|
"integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1",
|
"@babel/helper-plugin-utils": "^7.27.1",
|
||||||
"@babel/plugin-transform-destructuring": "^7.28.0"
|
"@babel/plugin-transform-destructuring": "^7.28.0"
|
||||||
@@ -1327,7 +1314,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.5.tgz",
|
||||||
"integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==",
|
"integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -1407,7 +1393,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz",
|
||||||
"integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==",
|
"integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -1453,7 +1438,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz",
|
||||||
"integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==",
|
"integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -1469,7 +1453,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz",
|
||||||
"integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==",
|
"integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-transforms": "^7.27.1",
|
"@babel/helper-module-transforms": "^7.27.1",
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
@@ -1502,7 +1485,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz",
|
||||||
"integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==",
|
"integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-transforms": "^7.28.3",
|
"@babel/helper-module-transforms": "^7.28.3",
|
||||||
"@babel/helper-plugin-utils": "^7.27.1",
|
"@babel/helper-plugin-utils": "^7.27.1",
|
||||||
@@ -1521,7 +1503,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz",
|
||||||
"integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==",
|
"integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-transforms": "^7.27.1",
|
"@babel/helper-module-transforms": "^7.27.1",
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
@@ -1554,7 +1535,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz",
|
||||||
"integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==",
|
"integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -1619,7 +1599,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz",
|
||||||
"integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==",
|
"integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1",
|
"@babel/helper-plugin-utils": "^7.27.1",
|
||||||
"@babel/helper-replace-supers": "^7.27.1"
|
"@babel/helper-replace-supers": "^7.27.1"
|
||||||
@@ -1715,7 +1694,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz",
|
||||||
"integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==",
|
"integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -1841,7 +1819,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz",
|
||||||
"integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==",
|
"integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-create-regexp-features-plugin": "^7.27.1",
|
"@babel/helper-create-regexp-features-plugin": "^7.27.1",
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
@@ -1858,7 +1835,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz",
|
||||||
"integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==",
|
"integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -1955,7 +1931,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz",
|
||||||
"integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==",
|
"integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -1990,7 +1965,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
|
||||||
"integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==",
|
"integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -2006,7 +1980,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz",
|
||||||
"integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==",
|
"integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-create-regexp-features-plugin": "^7.27.1",
|
"@babel/helper-create-regexp-features-plugin": "^7.27.1",
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
@@ -2039,7 +2012,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz",
|
||||||
"integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==",
|
"integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-create-regexp-features-plugin": "^7.27.1",
|
"@babel/helper-create-regexp-features-plugin": "^7.27.1",
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
@@ -2158,7 +2130,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz",
|
||||||
"integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==",
|
"integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.0.0",
|
"@babel/helper-plugin-utils": "^7.0.0",
|
||||||
"@babel/types": "^7.4.4",
|
"@babel/types": "^7.4.4",
|
||||||
@@ -6268,6 +6239,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.23.1.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.23.1.tgz",
|
||||||
"integrity": "sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA==",
|
"integrity": "sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"merge-options": "^3.0.4"
|
"merge-options": "^3.0.4"
|
||||||
},
|
},
|
||||||
@@ -6713,6 +6685,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.26.tgz",
|
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.26.tgz",
|
||||||
"integrity": "sha512-RhKmeD0E2ejzKS6z8elAfdfwShpcdkYY8zJzvHYLq+wv183BBcElTeyMLcIX6wIn7QutXeI92Yi21t7aUWfqNQ==",
|
"integrity": "sha512-RhKmeD0E2ejzKS6z8elAfdfwShpcdkYY8zJzvHYLq+wv183BBcElTeyMLcIX6wIn7QutXeI92Yi21t7aUWfqNQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-navigation/core": "^7.13.7",
|
"@react-navigation/core": "^7.13.7",
|
||||||
"escape-string-regexp": "^4.0.0",
|
"escape-string-regexp": "^4.0.0",
|
||||||
@@ -7884,7 +7857,6 @@
|
|||||||
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
|
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "*",
|
"@types/estree": "*",
|
||||||
"@types/json-schema": "*"
|
"@types/json-schema": "*"
|
||||||
@@ -7896,7 +7868,6 @@
|
|||||||
"integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
|
"integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/eslint": "*",
|
"@types/eslint": "*",
|
||||||
"@types/estree": "*"
|
"@types/estree": "*"
|
||||||
@@ -8110,6 +8081,7 @@
|
|||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -8121,6 +8093,7 @@
|
|||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
@@ -8237,7 +8210,6 @@
|
|||||||
"integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
|
"integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@webassemblyjs/helper-numbers": "1.13.2",
|
"@webassemblyjs/helper-numbers": "1.13.2",
|
||||||
"@webassemblyjs/helper-wasm-bytecode": "1.13.2"
|
"@webassemblyjs/helper-wasm-bytecode": "1.13.2"
|
||||||
@@ -8248,24 +8220,21 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
|
||||||
"integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
|
"integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@webassemblyjs/helper-api-error": {
|
"node_modules/@webassemblyjs/helper-api-error": {
|
||||||
"version": "1.13.2",
|
"version": "1.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
|
||||||
"integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
|
"integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@webassemblyjs/helper-buffer": {
|
"node_modules/@webassemblyjs/helper-buffer": {
|
||||||
"version": "1.14.1",
|
"version": "1.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
|
||||||
"integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
|
"integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@webassemblyjs/helper-numbers": {
|
"node_modules/@webassemblyjs/helper-numbers": {
|
||||||
"version": "1.13.2",
|
"version": "1.13.2",
|
||||||
@@ -8273,7 +8242,6 @@
|
|||||||
"integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
|
"integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@webassemblyjs/floating-point-hex-parser": "1.13.2",
|
"@webassemblyjs/floating-point-hex-parser": "1.13.2",
|
||||||
"@webassemblyjs/helper-api-error": "1.13.2",
|
"@webassemblyjs/helper-api-error": "1.13.2",
|
||||||
@@ -8285,8 +8253,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
|
||||||
"integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
|
"integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@webassemblyjs/helper-wasm-section": {
|
"node_modules/@webassemblyjs/helper-wasm-section": {
|
||||||
"version": "1.14.1",
|
"version": "1.14.1",
|
||||||
@@ -8294,7 +8261,6 @@
|
|||||||
"integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
|
"integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@webassemblyjs/ast": "1.14.1",
|
"@webassemblyjs/ast": "1.14.1",
|
||||||
"@webassemblyjs/helper-buffer": "1.14.1",
|
"@webassemblyjs/helper-buffer": "1.14.1",
|
||||||
@@ -8308,7 +8274,6 @@
|
|||||||
"integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
|
"integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xtuc/ieee754": "^1.2.0"
|
"@xtuc/ieee754": "^1.2.0"
|
||||||
}
|
}
|
||||||
@@ -8319,7 +8284,6 @@
|
|||||||
"integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
|
"integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xtuc/long": "4.2.2"
|
"@xtuc/long": "4.2.2"
|
||||||
}
|
}
|
||||||
@@ -8329,8 +8293,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
|
||||||
"integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
|
"integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@webassemblyjs/wasm-edit": {
|
"node_modules/@webassemblyjs/wasm-edit": {
|
||||||
"version": "1.14.1",
|
"version": "1.14.1",
|
||||||
@@ -8338,7 +8301,6 @@
|
|||||||
"integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
|
"integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@webassemblyjs/ast": "1.14.1",
|
"@webassemblyjs/ast": "1.14.1",
|
||||||
"@webassemblyjs/helper-buffer": "1.14.1",
|
"@webassemblyjs/helper-buffer": "1.14.1",
|
||||||
@@ -8356,7 +8318,6 @@
|
|||||||
"integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
|
"integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@webassemblyjs/ast": "1.14.1",
|
"@webassemblyjs/ast": "1.14.1",
|
||||||
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
|
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
|
||||||
@@ -8371,7 +8332,6 @@
|
|||||||
"integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
|
"integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@webassemblyjs/ast": "1.14.1",
|
"@webassemblyjs/ast": "1.14.1",
|
||||||
"@webassemblyjs/helper-buffer": "1.14.1",
|
"@webassemblyjs/helper-buffer": "1.14.1",
|
||||||
@@ -8385,7 +8345,6 @@
|
|||||||
"integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
|
"integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@webassemblyjs/ast": "1.14.1",
|
"@webassemblyjs/ast": "1.14.1",
|
||||||
"@webassemblyjs/helper-api-error": "1.13.2",
|
"@webassemblyjs/helper-api-error": "1.13.2",
|
||||||
@@ -8401,7 +8360,6 @@
|
|||||||
"integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
|
"integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@webassemblyjs/ast": "1.14.1",
|
"@webassemblyjs/ast": "1.14.1",
|
||||||
"@xtuc/long": "4.2.2"
|
"@xtuc/long": "4.2.2"
|
||||||
@@ -8422,16 +8380,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
||||||
"integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
|
"integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@xtuc/long": {
|
"node_modules/@xtuc/long": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
|
||||||
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
|
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/abab": {
|
"node_modules/abab": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
@@ -8471,6 +8427,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -8495,7 +8452,6 @@
|
|||||||
"integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
|
"integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
},
|
},
|
||||||
@@ -8571,6 +8527,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@@ -9160,8 +9117,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
"license": "ISC",
|
"license": "ISC"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/bplist-creator": {
|
"node_modules/bplist-creator": {
|
||||||
"version": "0.0.7",
|
"version": "0.0.7",
|
||||||
@@ -9225,6 +9181,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -9621,7 +9578,6 @@
|
|||||||
"integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
|
"integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0"
|
"node": ">=6.0"
|
||||||
}
|
}
|
||||||
@@ -10113,7 +10069,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"boolbase": "^1.0.0",
|
"boolbase": "^1.0.0",
|
||||||
"css-what": "^6.1.0",
|
"css-what": "^6.1.0",
|
||||||
@@ -10130,7 +10085,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
|
||||||
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
|
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mdn-data": "2.0.14",
|
"mdn-data": "2.0.14",
|
||||||
"source-map": "^0.6.1"
|
"source-map": "^0.6.1"
|
||||||
@@ -10144,7 +10098,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -10154,7 +10107,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
},
|
},
|
||||||
@@ -10687,7 +10639,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"domelementtype": "^2.3.0",
|
"domelementtype": "^2.3.0",
|
||||||
"domhandler": "^5.0.2",
|
"domhandler": "^5.0.2",
|
||||||
@@ -10707,8 +10658,7 @@
|
|||||||
"url": "https://github.com/sponsors/fb55"
|
"url": "https://github.com/sponsors/fb55"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/domexception": {
|
"node_modules/domexception": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
@@ -10739,7 +10689,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"domelementtype": "^2.3.0"
|
"domelementtype": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -10755,7 +10704,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dom-serializer": "^2.0.0",
|
"dom-serializer": "^2.0.0",
|
||||||
"domelementtype": "^2.3.0",
|
"domelementtype": "^2.3.0",
|
||||||
@@ -10899,7 +10847,8 @@
|
|||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
|
||||||
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
|
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
@@ -10931,7 +10880,6 @@
|
|||||||
"integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
|
"integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.4",
|
"graceful-fs": "^4.2.4",
|
||||||
"tapable": "^2.2.0"
|
"tapable": "^2.2.0"
|
||||||
@@ -10945,7 +10893,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12"
|
"node": ">=0.12"
|
||||||
},
|
},
|
||||||
@@ -11003,8 +10950,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||||
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
|
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
@@ -11148,7 +11094,6 @@
|
|||||||
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
|
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esrecurse": "^4.3.0",
|
"esrecurse": "^4.3.0",
|
||||||
"estraverse": "^4.1.1"
|
"estraverse": "^4.1.1"
|
||||||
@@ -11163,7 +11108,6 @@
|
|||||||
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
|
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
@@ -11187,7 +11131,6 @@
|
|||||||
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
|
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"estraverse": "^5.2.0"
|
"estraverse": "^5.2.0"
|
||||||
},
|
},
|
||||||
@@ -11571,6 +11514,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/expo/-/expo-52.0.48.tgz",
|
"resolved": "https://registry.npmjs.org/expo/-/expo-52.0.48.tgz",
|
||||||
"integrity": "sha512-/HR/vuo57KGEWlvF3GWaquwEAjXuA5hrOCsaLcZ3pMSA8mQ27qKd1jva4GWzpxXYedlzs/7LLP1XpZo6hXTsog==",
|
"integrity": "sha512-/HR/vuo57KGEWlvF3GWaquwEAjXuA5hrOCsaLcZ3pMSA8mQ27qKd1jva4GWzpxXYedlzs/7LLP1XpZo6hXTsog==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.20.0",
|
"@babel/runtime": "^7.20.0",
|
||||||
"@expo/cli": "0.22.27",
|
"@expo/cli": "0.22.27",
|
||||||
@@ -11720,6 +11664,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.0.8.tgz",
|
||||||
"integrity": "sha512-XfWRyQAf1yUNgWZ1TnE8pFBMqGmFP5Gb+SFSgszxDdOoheB/NI5D4p7q86kI2fvGyfTrxAe+D+74nZkfsGvUlg==",
|
"integrity": "sha512-XfWRyQAf1yUNgWZ1TnE8pFBMqGmFP5Gb+SFSgszxDdOoheB/NI5D4p7q86kI2fvGyfTrxAe+D+74nZkfsGvUlg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/config": "~10.0.11",
|
"@expo/config": "~10.0.11",
|
||||||
"@expo/env": "~0.4.2"
|
"@expo/env": "~0.4.2"
|
||||||
@@ -11802,6 +11747,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.12.tgz",
|
||||||
"integrity": "sha512-HAkrd/mb8r+G3lJ9MzmGeuW2B+BxQR1joKfeCyY4deLl1zoZ48FrAWjgZjHK9aHUVhJ0ehzInu/NQtikKytaeg==",
|
"integrity": "sha512-HAkrd/mb8r+G3lJ9MzmGeuW2B+BxQR1joKfeCyY4deLl1zoZ48FrAWjgZjHK9aHUVhJ0ehzInu/NQtikKytaeg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"web-streams-polyfill": "^3.3.2"
|
"web-streams-polyfill": "^3.3.2"
|
||||||
},
|
},
|
||||||
@@ -11902,6 +11848,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.0.5.tgz",
|
||||||
"integrity": "sha512-3KptlJtcYDPWohk0MfJU75MJFh2ybavbtcSd84zEPfw9s1q3hjimw3sXnH03ZxP54kiEWldvKmmnGcVffBDB1g==",
|
"integrity": "sha512-3KptlJtcYDPWohk0MfJU75MJFh2ybavbtcSd84zEPfw9s1q3hjimw3sXnH03ZxP54kiEWldvKmmnGcVffBDB1g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"expo-constants": "~17.0.5",
|
"expo-constants": "~17.0.5",
|
||||||
"invariant": "^2.2.4"
|
"invariant": "^2.2.4"
|
||||||
@@ -12669,8 +12616,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
|
||||||
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
|
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/globby": {
|
"node_modules/globby": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
@@ -13670,6 +13616,7 @@
|
|||||||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jest/core": "^29.7.0",
|
"@jest/core": "^29.7.0",
|
||||||
"@jest/types": "^29.6.3",
|
"@jest/types": "^29.6.3",
|
||||||
@@ -14112,8 +14059,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-6230622a1a-20240610.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-6230622a1a-20240610.tgz",
|
||||||
"integrity": "sha512-GTIQdJXthps5mgkIFo7yAq03M0QQYTfN8z+GrnMC/SCKFSuyFP5tk2BMaaWUsVy4u4r+dTLdiXH8JEivVls0Bw==",
|
"integrity": "sha512-GTIQdJXthps5mgkIFo7yAq03M0QQYTfN8z+GrnMC/SCKFSuyFP5tk2BMaaWUsVy4u4r+dTLdiXH8JEivVls0Bw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/jest-get-type": {
|
"node_modules/jest-get-type": {
|
||||||
"version": "29.6.3",
|
"version": "29.6.3",
|
||||||
@@ -14688,6 +14634,7 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -14985,6 +14932,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.27.0.tgz",
|
||||||
"integrity": "sha512-8f7aNmS1+etYSLHht0fQApPc2kNO8qGRutifN5rVIc6Xo6ABsEbqOr758UwI7ALVbTt4x1fllKt0PYgzD9S3yQ==",
|
"integrity": "sha512-8f7aNmS1+etYSLHht0fQApPc2kNO8qGRutifN5rVIc6Xo6ABsEbqOr758UwI7ALVbTt4x1fllKt0PYgzD9S3yQ==",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^1.0.3"
|
"detect-libc": "^1.0.3"
|
||||||
},
|
},
|
||||||
@@ -15233,7 +15181,6 @@
|
|||||||
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
|
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.11.5"
|
"node": ">=6.11.5"
|
||||||
},
|
},
|
||||||
@@ -15474,8 +15421,7 @@
|
|||||||
"version": "2.0.14",
|
"version": "2.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||||
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
|
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
|
||||||
"license": "CC0-1.0",
|
"license": "CC0-1.0"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/memoize-one": {
|
"node_modules/memoize-one": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
@@ -16321,7 +16267,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"boolbase": "^1.0.0"
|
"boolbase": "^1.0.0"
|
||||||
},
|
},
|
||||||
@@ -16990,6 +16935,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -17392,7 +17338,6 @@
|
|||||||
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
|
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "^5.1.0"
|
"safe-buffer": "^5.1.0"
|
||||||
}
|
}
|
||||||
@@ -17426,6 +17371,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -17469,6 +17415,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -17512,17 +17459,28 @@
|
|||||||
"react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0"
|
"react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-icons": {
|
||||||
|
"version": "4.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.12.0.tgz",
|
||||||
|
"integrity": "sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
|
||||||
"integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==",
|
"integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/react-native": {
|
"node_modules/react-native": {
|
||||||
"version": "0.76.9",
|
"version": "0.76.9",
|
||||||
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.76.9.tgz",
|
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.76.9.tgz",
|
||||||
"integrity": "sha512-+LRwecWmTDco7OweGsrECIqJu0iyrREd6CTCgC/uLLYipiHvk+MH9nd6drFtCw/6Blz6eoKTcH9YTTJusNtrWg==",
|
"integrity": "sha512-+LRwecWmTDco7OweGsrECIqJu0iyrREd6CTCgC/uLLYipiHvk+MH9nd6drFtCw/6Blz6eoKTcH9YTTJusNtrWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jest/create-cache-key-function": "^29.6.3",
|
"@jest/create-cache-key-function": "^29.6.3",
|
||||||
"@react-native/assets-registry": "0.76.9",
|
"@react-native/assets-registry": "0.76.9",
|
||||||
@@ -17691,6 +17649,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.16.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.16.7.tgz",
|
||||||
"integrity": "sha512-qoUUQOwE1pHlmQ9cXTJ2MX9FQ9eHllopCLiWOkDkp6CER95ZWeXhJCP4cSm6AD4jigL5jHcZf/SkWrg8ttZUsw==",
|
"integrity": "sha512-qoUUQOwE1pHlmQ9cXTJ2MX9FQ9eHllopCLiWOkDkp6CER95ZWeXhJCP4cSm6AD4jigL5jHcZf/SkWrg8ttZUsw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/plugin-transform-arrow-functions": "^7.0.0-0",
|
"@babel/plugin-transform-arrow-functions": "^7.0.0-0",
|
||||||
"@babel/plugin-transform-class-properties": "^7.0.0-0",
|
"@babel/plugin-transform-class-properties": "^7.0.0-0",
|
||||||
@@ -17715,6 +17674,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.12.0.tgz",
|
||||||
"integrity": "sha512-ukk5PxcF4p3yu6qMZcmeiZgowhb5AsKRnil54YFUUAXVIS7PJcMHGGC+q44fCiBg44/1AJk5njGMez1m9H0BVQ==",
|
"integrity": "sha512-ukk5PxcF4p3yu6qMZcmeiZgowhb5AsKRnil54YFUUAXVIS7PJcMHGGC+q44fCiBg44/1AJk5njGMez1m9H0BVQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "*",
|
"react": "*",
|
||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
@@ -17725,6 +17685,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.4.0.tgz",
|
||||||
"integrity": "sha512-c7zc7Zwjty6/pGyuuvh9gK3YBYqHPOxrhXfG1lF4gHlojQSmIx2piNbNaV+Uykj+RDTmFXK0e/hA+fucw/Qozg==",
|
"integrity": "sha512-c7zc7Zwjty6/pGyuuvh9gK3YBYqHPOxrhXfG1lF4gHlojQSmIx2piNbNaV+Uykj+RDTmFXK0e/hA+fucw/Qozg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-freeze": "^1.0.0",
|
"react-freeze": "^1.0.0",
|
||||||
"warn-once": "^0.1.0"
|
"warn-once": "^0.1.0"
|
||||||
@@ -17767,6 +17728,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz",
|
||||||
"integrity": "sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A==",
|
"integrity": "sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.6",
|
"@babel/runtime": "^7.18.6",
|
||||||
"@react-native/normalize-colors": "^0.74.1",
|
"@react-native/normalize-colors": "^0.74.1",
|
||||||
@@ -17865,6 +17827,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.4.0"
|
||||||
@@ -18021,6 +17984,7 @@
|
|||||||
"integrity": "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==",
|
"integrity": "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-is": "^18.3.1",
|
"react-is": "^18.3.1",
|
||||||
"react-shallow-renderer": "^16.15.0",
|
"react-shallow-renderer": "^16.15.0",
|
||||||
@@ -18201,7 +18165,8 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/redux-thunk": {
|
"node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@@ -18706,7 +18671,6 @@
|
|||||||
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
|
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"randombytes": "^2.1.0"
|
"randombytes": "^2.1.0"
|
||||||
}
|
}
|
||||||
@@ -19504,7 +19468,6 @@
|
|||||||
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
},
|
},
|
||||||
@@ -19679,6 +19642,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
|
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
|
||||||
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
|
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/source-map": "^0.3.3",
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
"acorn": "^8.15.0",
|
"acorn": "^8.15.0",
|
||||||
@@ -19698,7 +19662,6 @@
|
|||||||
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
|
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
"jest-worker": "^27.4.5",
|
"jest-worker": "^27.4.5",
|
||||||
@@ -19734,7 +19697,6 @@
|
|||||||
"integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
|
"integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
"merge-stream": "^2.0.0",
|
"merge-stream": "^2.0.0",
|
||||||
@@ -19750,7 +19712,6 @@
|
|||||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-flag": "^4.0.0"
|
"has-flag": "^4.0.0"
|
||||||
},
|
},
|
||||||
@@ -20365,6 +20326,7 @@
|
|||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -20459,7 +20421,6 @@
|
|||||||
"integrity": "sha512-e6vZvY6xboSwLz2GD36c16+O/2Z6fKvIf4pOXptw2rY9MVwE/TXc6RGqxD3I3x0a28lwBY7DE+76uTPSsBrrCA==",
|
"integrity": "sha512-e6vZvY6xboSwLz2GD36c16+O/2Z6fKvIf4pOXptw2rY9MVwE/TXc6RGqxD3I3x0a28lwBY7DE+76uTPSsBrrCA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"glob-to-regexp": "^0.4.1",
|
"glob-to-regexp": "^0.4.1",
|
||||||
"graceful-fs": "^4.1.2"
|
"graceful-fs": "^4.1.2"
|
||||||
@@ -20548,7 +20509,6 @@
|
|||||||
"integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==",
|
"integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
@@ -20739,6 +20699,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
},
|
},
|
||||||
@@ -20845,6 +20806,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,46 +16,46 @@ const DIMENSIONS = {
|
|||||||
// Screenshot configurations with marketing text
|
// Screenshot configurations with marketing text
|
||||||
const SCREENSHOTS = [
|
const SCREENSHOTS = [
|
||||||
{
|
{
|
||||||
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 19.57.14.png",
|
source: "Simulator Screenshot - iPhone 16e - 2026-01-08 at 19.18.40.png",
|
||||||
headline: "Your Couple Dashboard",
|
headline: "Your Couple Dashboard",
|
||||||
subheadline: "Track matches, packs & explore together",
|
subheadline: "Track matches, packs & explore together",
|
||||||
outputName: "01-home",
|
outputName: "01-home",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 20.10.53.png",
|
source: "Simulator Screenshot - iPhone 16e - 2026-01-08 at 19.10.03.png",
|
||||||
headline: "Curated Question Packs",
|
headline: "Curated Question Packs",
|
||||||
subheadline: "From first dates to spicy adventures",
|
subheadline: "From first dates to spicy adventures",
|
||||||
outputName: "02-packs",
|
outputName: "02-packs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 19.57.35.png",
|
source: "Simulator Screenshot - iPhone 16e - 2026-01-08 at 19.06.40.png",
|
||||||
headline: "Swipe on Questions",
|
headline: "Swipe on Questions",
|
||||||
subheadline: "Answer yes, no, or maybe together",
|
subheadline: "Answer yes, no, or maybe together",
|
||||||
outputName: "03-swipe",
|
outputName: "03-swipe",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 21.00.55.png",
|
source: "Simulator Screenshot - iPhone 16e - 2026-01-08 at 19.07.29.png",
|
||||||
headline: "Express Yourself",
|
|
||||||
subheadline: "Swipe right on what excites you",
|
|
||||||
outputName: "04-swipe-yes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 19.57.51.png",
|
|
||||||
headline: "It's a Match!",
|
headline: "It's a Match!",
|
||||||
subheadline: "Celebrate when you both say yes",
|
subheadline: "Celebrate when you both say yes",
|
||||||
outputName: "05-match",
|
outputName: "04-match",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 20.02.50.png",
|
source: "Simulator Screenshot - iPhone 16e - 2026-01-08 at 19.09.54.png",
|
||||||
headline: "Discover Your Matches",
|
headline: "Discover Your Matches",
|
||||||
subheadline: "See where you both agree",
|
subheadline: "See where you both agree",
|
||||||
outputName: "06-home-matches",
|
outputName: "05-matches",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "Simulator Screenshot - iPhone 16 Pro - 2025-12-30 at 20.01.31.png",
|
source: "Simulator Screenshot - iPhone 16e - 2026-01-08 at 19.08.25.png",
|
||||||
headline: "Chat About It",
|
headline: "Chat About It",
|
||||||
subheadline: "Every match unlocks a conversation",
|
subheadline: "Every match unlocks a conversation",
|
||||||
outputName: "07-chat",
|
outputName: "06-chat",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "Simulator Screenshot - iPhone 16e - 2026-01-08 at 19.09.48.png",
|
||||||
|
headline: "Set Your Comfort Zone",
|
||||||
|
subheadline: "Customize your intimacy preferences",
|
||||||
|
outputName: "07-settings",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 629 KiB After Width: | Height: | Size: 658 KiB |
|
Before Width: | Height: | Size: 858 KiB After Width: | Height: | Size: 837 KiB |
|
Before Width: | Height: | Size: 852 KiB After Width: | Height: | Size: 757 KiB |
|
Before Width: | Height: | Size: 567 KiB After Width: | Height: | Size: 561 KiB |
|
Before Width: | Height: | Size: 757 KiB After Width: | Height: | Size: 683 KiB |
|
Before Width: | Height: | Size: 751 KiB After Width: | Height: | Size: 596 KiB |
|
Before Width: | Height: | Size: 381 KiB After Width: | Height: | Size: 592 KiB |
|
Before Width: | Height: | Size: 502 KiB After Width: | Height: | Size: 748 KiB |
|
Before Width: | Height: | Size: 497 KiB After Width: | Height: | Size: 628 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 437 KiB |
|
Before Width: | Height: | Size: 588 KiB |
|
Before Width: | Height: | Size: 589 KiB |
|
Before Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 855 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 578 KiB |
|
Before Width: | Height: | Size: 798 KiB |
|
Before Width: | Height: | Size: 790 KiB |
|
Before Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 534 KiB |
|
Before Width: | Height: | Size: 525 KiB |
|
After Width: | Height: | Size: 469 KiB |
|
After Width: | Height: | Size: 591 KiB |
|
After Width: | Height: | Size: 554 KiB |