mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
Enable admin question types and category grouping
Keep shared linting configured for the repo.
This commit is contained in:
1 parent
6c2e471d47
commit
8612b16b93
9 files changed
+415
-19
No files matched your search
@@ -60,6 +60,14 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/categories/:categoryId/dare-packs"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_PACKS}>
|
||||
<DarePacksPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/packs/:packId/questions"
|
||||
element={
|
||||
|
||||
@@ -26,6 +26,16 @@ import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
|
||||
|
||||
const QUESTION_TYPE_LABELS: Record<QuestionType, string> = {
|
||||
swipe: 'Swipe',
|
||||
text_answer: 'Text Answer',
|
||||
audio: 'Audio',
|
||||
photo: 'Photo',
|
||||
who_likely: 'Who Is More Likely',
|
||||
};
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
text: string;
|
||||
@@ -34,6 +44,7 @@ interface Question {
|
||||
allowed_couple_genders: string[] | null;
|
||||
target_user_genders: string[] | null;
|
||||
required_props?: string[] | null;
|
||||
question_type?: QuestionType | null;
|
||||
}
|
||||
|
||||
interface ReviewQuestionsDialogProps {
|
||||
@@ -601,6 +612,11 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
||||
{/* Question Column */}
|
||||
<TableCell className="align-top py-3">
|
||||
<div className="text-sm font-medium">{suggestion.question.text}</div>
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{QUESTION_TYPE_LABELS[suggestion.question.question_type ?? 'swipe']}
|
||||
</Badge>
|
||||
</div>
|
||||
{suggestion.question.partner_text && (
|
||||
<div className="text-xs text-muted-foreground mt-1.5 italic">
|
||||
Partner: {suggestion.question.partner_text}
|
||||
|
||||
@@ -56,7 +56,8 @@ interface Category {
|
||||
sort_order: number | null;
|
||||
created_at: string | null;
|
||||
is_public: boolean;
|
||||
pack_count?: number;
|
||||
question_pack_count?: number;
|
||||
dare_pack_count?: number;
|
||||
}
|
||||
|
||||
interface CategoryFormData {
|
||||
@@ -134,9 +135,14 @@ function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-auto">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{category.pack_count} pack{category.pack_count !== 1 ? 's' : ''}
|
||||
{category.question_pack_count} question pack{category.question_pack_count !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{category.dare_pack_count} dare pack{category.dare_pack_count !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
@@ -158,11 +164,18 @@ function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Link to={`/categories/${category.id}/packs`}>
|
||||
<Button variant="outline" className="w-full">
|
||||
View Packs
|
||||
View Question Packs
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={`/categories/${category.id}/dare-packs`}>
|
||||
<Button variant="outline" className="w-full">
|
||||
View Dare Packs
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -213,17 +226,30 @@ export function CategoriesPage() {
|
||||
setTotalCount(count || 0);
|
||||
|
||||
const categoryIds = (cats || []).map(c => c.id);
|
||||
const packCounts: Record<string, number> = {};
|
||||
const questionPackCounts: Record<string, number> = {};
|
||||
const darePackCounts: Record<string, number> = {};
|
||||
|
||||
if (categoryIds.length > 0) {
|
||||
const { data: packs } = await supabase
|
||||
const [{ data: packs }, { data: darePacks }] = await Promise.all([
|
||||
supabase
|
||||
.from('question_packs')
|
||||
.select('category_id')
|
||||
.in('category_id', categoryIds);
|
||||
.in('category_id', categoryIds),
|
||||
supabase
|
||||
.from('dare_packs')
|
||||
.select('category_id')
|
||||
.in('category_id', categoryIds),
|
||||
]);
|
||||
|
||||
packs?.forEach(p => {
|
||||
if (p.category_id) {
|
||||
packCounts[p.category_id] = (packCounts[p.category_id] || 0) + 1;
|
||||
questionPackCounts[p.category_id] = (questionPackCounts[p.category_id] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
darePacks?.forEach(p => {
|
||||
if (p.category_id) {
|
||||
darePackCounts[p.category_id] = (darePackCounts[p.category_id] || 0) + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -231,7 +257,8 @@ export function CategoriesPage() {
|
||||
setCategories(
|
||||
(cats || []).map(c => ({
|
||||
...c,
|
||||
pack_count: packCounts[c.id] || 0,
|
||||
question_pack_count: questionPackCounts[c.id] || 0,
|
||||
dare_pack_count: darePackCounts[c.id] || 0,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||
@@ -39,6 +39,7 @@ import { toast } from 'sonner';
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
interface DarePack {
|
||||
@@ -73,9 +74,12 @@ interface DarePackFormData {
|
||||
// =============================================================================
|
||||
|
||||
export function DarePacksPage() {
|
||||
const { categoryId } = useParams<{ categoryId: string }>();
|
||||
|
||||
// Data state
|
||||
const [darePacks, setDarePacks] = useState<DarePack[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [category, setCategory] = useState<Category | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
@@ -90,7 +94,7 @@ export function DarePacksPage() {
|
||||
is_premium: false,
|
||||
is_public: false,
|
||||
is_explicit: false,
|
||||
category_id: '',
|
||||
category_id: categoryId ?? '',
|
||||
},
|
||||
(pack) => ({
|
||||
name: pack.name,
|
||||
@@ -113,13 +117,31 @@ export function DarePacksPage() {
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
// Fetch category info
|
||||
if (categoryId) {
|
||||
const { data: categoryData } = await supabase
|
||||
.from('categories')
|
||||
.select('id, name, icon')
|
||||
.eq('id', categoryId)
|
||||
.single();
|
||||
setCategory(categoryData || null);
|
||||
} else {
|
||||
setCategory(null);
|
||||
}
|
||||
|
||||
// Fetch dare packs
|
||||
const { data: packData, error, count } = await supabase
|
||||
let query = supabase
|
||||
.from('dare_packs')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('sort_order', { ascending: true })
|
||||
.range(from, to);
|
||||
|
||||
if (categoryId) {
|
||||
query = query.eq('category_id', categoryId);
|
||||
}
|
||||
|
||||
const { data: packData, error, count } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setTotalCount(count || 0);
|
||||
@@ -159,12 +181,16 @@ export function DarePacksPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize]);
|
||||
}, [page, pageSize, categoryId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [categoryId]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
if (page > totalPages) {
|
||||
@@ -305,16 +331,27 @@ export function DarePacksPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
{category ? (
|
||||
<>
|
||||
<IconPreview value={category.icon} fallback="flame-outline" className="text-3xl" />
|
||||
{category.name}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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
|
||||
{category
|
||||
? `Manage dare packs in the ${category.name} category`
|
||||
: '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}>
|
||||
<Button onClick={() => form.openCreateWith({ category_id: categoryId ?? '' })}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Dare Pack
|
||||
</Button>
|
||||
|
||||
@@ -7,12 +7,15 @@ import { Button } from '@/components/ui/button';
|
||||
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Cell, Pie, PieChart as RechartsPieChart } from 'recharts';
|
||||
import { Crown, MessageCircle, PieChart, Sparkles, Tag, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
|
||||
|
||||
interface QuestionRow {
|
||||
id: string;
|
||||
pack_id: string;
|
||||
intensity: number | null;
|
||||
partner_text: string | null;
|
||||
inverse_of?: string | null;
|
||||
question_type?: QuestionType | null;
|
||||
}
|
||||
|
||||
interface PackRow {
|
||||
@@ -62,6 +65,14 @@ type SegmentKey = 'all' | 'premium' | 'free';
|
||||
const intensityPalette = ['#22c55e', '#84cc16', '#facc15', '#f97316', '#ef4444'];
|
||||
const intensityLabels = ['Gentle', 'Warm', 'Playful', 'Steamy', 'Intense'];
|
||||
|
||||
const QUESTION_TYPE_CONFIG: Record<QuestionType, { label: string; color: string }> = {
|
||||
swipe: { label: 'Swipe', color: '#38bdf8' },
|
||||
text_answer: { label: 'Text', color: '#f59e0b' },
|
||||
audio: { label: 'Audio', color: '#a78bfa' },
|
||||
photo: { label: 'Photo', color: '#f472b6' },
|
||||
who_likely: { label: 'Who Likely', color: '#22c55e' },
|
||||
};
|
||||
|
||||
const formatPercent = (count: number, total: number) => (total > 0 ? Math.round((count / total) * 100) : 0);
|
||||
|
||||
const getCategoryName = (category?: PackRow['category']) => {
|
||||
@@ -89,6 +100,7 @@ export function QuestionAnalyticsPage() {
|
||||
taggedQuestions: 0,
|
||||
});
|
||||
const [premiumBreakdown, setPremiumBreakdown] = useState<ChartDatum[]>([]);
|
||||
const [questionTypeBreakdown, setQuestionTypeBreakdown] = useState<ChartDatum[]>([]);
|
||||
const [intensityBreakdown, setIntensityBreakdown] = useState<Record<SegmentKey, ChartDatum[]>>({
|
||||
all: [],
|
||||
premium: [],
|
||||
@@ -125,7 +137,7 @@ export function QuestionAnalyticsPage() {
|
||||
const to = from + pageSize - 1;
|
||||
const { data, error } = await supabase
|
||||
.from('questions')
|
||||
.select('id, pack_id, intensity, partner_text, inverse_of')
|
||||
.select('id, pack_id, intensity, partner_text, inverse_of, question_type')
|
||||
.range(from, to);
|
||||
|
||||
if (error) throw error;
|
||||
@@ -210,6 +222,13 @@ export function QuestionAnalyticsPage() {
|
||||
free: { ...emptyIntensityCounts },
|
||||
};
|
||||
const untaggedCounts: Record<SegmentKey, number> = { all: 0, premium: 0, free: 0 };
|
||||
const questionTypeCounts: Record<QuestionType, number> = {
|
||||
swipe: 0,
|
||||
text_answer: 0,
|
||||
audio: 0,
|
||||
photo: 0,
|
||||
who_likely: 0,
|
||||
};
|
||||
const categoryCountsBySegment: Record<SegmentKey, Map<string, BreakdownItem>> = {
|
||||
all: new Map(),
|
||||
premium: new Map(),
|
||||
@@ -233,6 +252,9 @@ export function QuestionAnalyticsPage() {
|
||||
const segmentKey: SegmentKey = isPremium ? 'premium' : 'free';
|
||||
const categoryName = getCategoryName(pack?.category);
|
||||
const categoryId = pack?.category_id || 'uncategorized';
|
||||
const questionType = question.question_type ?? 'swipe';
|
||||
|
||||
questionTypeCounts[questionType] = (questionTypeCounts[questionType] || 0) + 1;
|
||||
|
||||
if (isPremium) {
|
||||
premiumCount += 1;
|
||||
@@ -302,6 +324,13 @@ export function QuestionAnalyticsPage() {
|
||||
{ name: 'Premium', value: premiumCount, color: '#f59e0b' },
|
||||
{ name: 'Free', value: freeCount, color: '#22c55e' },
|
||||
];
|
||||
const questionTypeData: ChartDatum[] = (Object.entries(QUESTION_TYPE_CONFIG) as Array<
|
||||
[QuestionType, { label: string; color: string }]
|
||||
>).map(([key, config]) => ({
|
||||
name: config.label,
|
||||
value: questionTypeCounts[key],
|
||||
color: config.color,
|
||||
}));
|
||||
|
||||
const buildIntensityData = (key: SegmentKey) => intensityLabels.map((label, index) => ({
|
||||
name: label,
|
||||
@@ -355,6 +384,7 @@ export function QuestionAnalyticsPage() {
|
||||
taggedQuestions: taggedCount,
|
||||
});
|
||||
setPremiumBreakdown(premiumData);
|
||||
setQuestionTypeBreakdown(questionTypeData);
|
||||
setIntensityBreakdown(intensityData);
|
||||
setTopicBreakdown(topicData);
|
||||
setCategoryBreakdown(categoryData);
|
||||
@@ -645,6 +675,58 @@ export function QuestionAnalyticsPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Question Types</CardTitle>
|
||||
<CardDescription>
|
||||
Distribution of questions by answer format
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[240px] w-full" />
|
||||
) : stats.totalQuestions === 0 ? (
|
||||
<div className="flex items-center justify-center h-[240px] text-muted-foreground">
|
||||
No question data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[240px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={questionTypeBreakdown}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[6, 6, 0, 0]}>
|
||||
{questionTypeBreakdown.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
|
||||
@@ -52,6 +52,12 @@ interface QuestionPack {
|
||||
is_explicit: boolean;
|
||||
}
|
||||
|
||||
type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
|
||||
|
||||
interface QuestionConfig {
|
||||
max_duration_seconds?: number;
|
||||
}
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
pack_id: string;
|
||||
@@ -63,6 +69,8 @@ interface Question {
|
||||
required_props?: string[] | null;
|
||||
inverse_of?: string | null;
|
||||
created_at: string | null;
|
||||
question_type?: QuestionType | null;
|
||||
config?: QuestionConfig | null;
|
||||
}
|
||||
|
||||
import { INTENSITY_LEVELS } from '@/lib/openai';
|
||||
@@ -70,6 +78,30 @@ import { INTENSITY_LEVELS } from '@/lib/openai';
|
||||
// Helper to get intensity info by level (1-indexed)
|
||||
const getIntensity = (level: number) => INTENSITY_LEVELS[level - 1] || INTENSITY_LEVELS[0];
|
||||
|
||||
const DEFAULT_AUDIO_DURATION = 60;
|
||||
|
||||
const QUESTION_TYPE_OPTIONS: Array<{ value: QuestionType; label: string }> = [
|
||||
{ value: 'swipe', label: 'Swipe' },
|
||||
{ value: 'text_answer', label: 'Text Answer' },
|
||||
{ value: 'audio', label: 'Audio' },
|
||||
{ value: 'photo', label: 'Photo' },
|
||||
{ value: 'who_likely', label: 'Who Is More Likely' },
|
||||
];
|
||||
|
||||
const getQuestionTypeLabel = (type?: QuestionType | null) => {
|
||||
const resolved = type ?? 'swipe';
|
||||
return QUESTION_TYPE_OPTIONS.find(option => option.value === resolved)?.label ?? 'Swipe';
|
||||
};
|
||||
|
||||
const getConfigSummary = (question: Question) => {
|
||||
if (question.question_type === 'audio') {
|
||||
const duration = question.config?.max_duration_seconds ?? DEFAULT_AUDIO_DURATION;
|
||||
return `${duration}s max`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export function QuestionsPage() {
|
||||
const { packId } = useParams<{ packId: string }>();
|
||||
const [pack, setPack] = useState<QuestionPack | null>(null);
|
||||
@@ -90,6 +122,7 @@ export function QuestionsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [questionTypeFilter, setQuestionTypeFilter] = useState<QuestionType | 'all'>('all');
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<{
|
||||
@@ -100,6 +133,8 @@ export function QuestionsPage() {
|
||||
target_user_genders: string[];
|
||||
required_props: string;
|
||||
inverse_of: string;
|
||||
question_type: QuestionType;
|
||||
max_duration_seconds: string;
|
||||
}>({
|
||||
text: '',
|
||||
partner_text: '',
|
||||
@@ -108,6 +143,8 @@ export function QuestionsPage() {
|
||||
target_user_genders: [],
|
||||
required_props: '',
|
||||
inverse_of: '',
|
||||
question_type: 'swipe',
|
||||
max_duration_seconds: String(DEFAULT_AUDIO_DURATION),
|
||||
});
|
||||
|
||||
// All questions in pack for inverse_of dropdown (loaded once)
|
||||
@@ -126,12 +163,18 @@ export function QuestionsPage() {
|
||||
.select('id, name, description, icon, is_explicit')
|
||||
.eq('id', packId)
|
||||
.single();
|
||||
const questionsPromise = supabase
|
||||
let questionsQuery = supabase
|
||||
.from('questions')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('pack_id', packId)
|
||||
.order('created_at', { ascending: true })
|
||||
.range(from, to);
|
||||
|
||||
if (questionTypeFilter !== 'all') {
|
||||
questionsQuery = questionsQuery.eq('question_type', questionTypeFilter);
|
||||
}
|
||||
|
||||
const questionsPromise = questionsQuery;
|
||||
const allPackPromise = supabase
|
||||
.from('questions')
|
||||
.select('id, text, inverse_of')
|
||||
@@ -162,7 +205,7 @@ export function QuestionsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [packId, page, pageSize]);
|
||||
}, [packId, page, pageSize, questionTypeFilter]);
|
||||
|
||||
// Real-time subscription for questions in this pack
|
||||
const { status: realtimeStatus } = useRealtimeSubscription<Question>({
|
||||
@@ -203,6 +246,11 @@ export function QuestionsPage() {
|
||||
setReviewOpen(false);
|
||||
}, [packId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setSelectedIds(new Set());
|
||||
}, [questionTypeFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set());
|
||||
}, [page, pageSize]);
|
||||
@@ -233,6 +281,8 @@ export function QuestionsPage() {
|
||||
target_user_genders: [],
|
||||
required_props: '',
|
||||
inverse_of: '',
|
||||
question_type: 'swipe',
|
||||
max_duration_seconds: String(DEFAULT_AUDIO_DURATION),
|
||||
});
|
||||
await fetchAllPackQuestions();
|
||||
setDialogOpen(true);
|
||||
@@ -248,6 +298,8 @@ export function QuestionsPage() {
|
||||
target_user_genders: question.target_user_genders || [],
|
||||
required_props: question.required_props?.join(', ') ?? '',
|
||||
inverse_of: question.inverse_of || '',
|
||||
question_type: question.question_type ?? 'swipe',
|
||||
max_duration_seconds: String(question.config?.max_duration_seconds ?? DEFAULT_AUDIO_DURATION),
|
||||
});
|
||||
await fetchAllPackQuestions();
|
||||
setDialogOpen(true);
|
||||
@@ -264,6 +316,13 @@ export function QuestionsPage() {
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean);
|
||||
const requiredPropsValue = requiredProps.length > 0 ? requiredProps : null;
|
||||
const parsedMaxDuration = Number(formData.max_duration_seconds);
|
||||
const maxDuration = Number.isFinite(parsedMaxDuration) && parsedMaxDuration > 0
|
||||
? parsedMaxDuration
|
||||
: DEFAULT_AUDIO_DURATION;
|
||||
const configValue = formData.question_type === 'audio'
|
||||
? { max_duration_seconds: maxDuration }
|
||||
: null;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -276,6 +335,8 @@ export function QuestionsPage() {
|
||||
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
|
||||
required_props: requiredPropsValue,
|
||||
inverse_of: formData.inverse_of || null,
|
||||
question_type: formData.question_type,
|
||||
config: configValue,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
@@ -290,6 +351,8 @@ export function QuestionsPage() {
|
||||
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
|
||||
required_props: requiredPropsValue,
|
||||
inverse_of: formData.inverse_of || null,
|
||||
question_type: formData.question_type,
|
||||
config: configValue,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
@@ -324,6 +387,8 @@ export function QuestionsPage() {
|
||||
};
|
||||
|
||||
const handleAiGenerated = (generatedQuestions: Array<{ text: string; partner_text?: string; intensity: number; inverse_pair_id?: string | null }>) => {
|
||||
const generatedQuestionType: QuestionType = 'swipe';
|
||||
|
||||
// Bulk insert AI-generated questions with inverse_of linking
|
||||
const insertQuestions = async () => {
|
||||
try {
|
||||
@@ -351,6 +416,8 @@ export function QuestionsPage() {
|
||||
partner_text: q.partner_text || null,
|
||||
intensity: q.intensity,
|
||||
inverse_of: null,
|
||||
question_type: generatedQuestionType,
|
||||
config: null,
|
||||
}))
|
||||
);
|
||||
if (standaloneError) throw standaloneError;
|
||||
@@ -369,6 +436,8 @@ export function QuestionsPage() {
|
||||
partner_text: primary.partner_text || null,
|
||||
intensity: primary.intensity,
|
||||
inverse_of: null,
|
||||
question_type: generatedQuestionType,
|
||||
config: null,
|
||||
}]
|
||||
);
|
||||
if (primaryError) throw primaryError;
|
||||
@@ -385,6 +454,8 @@ export function QuestionsPage() {
|
||||
partner_text: inverse.partner_text || null,
|
||||
intensity: inverse.intensity,
|
||||
inverse_of: primaryId,
|
||||
question_type: generatedQuestionType,
|
||||
config: null,
|
||||
}]
|
||||
);
|
||||
if (inverseError) throw inverseError;
|
||||
@@ -401,6 +472,8 @@ export function QuestionsPage() {
|
||||
partner_text: extra.partner_text || null,
|
||||
intensity: extra.intensity,
|
||||
inverse_of: null,
|
||||
question_type: generatedQuestionType,
|
||||
config: null,
|
||||
}]
|
||||
);
|
||||
if (extraError) throw extraError;
|
||||
@@ -415,7 +488,10 @@ export function QuestionsPage() {
|
||||
partner_text: pair[0].partner_text || null,
|
||||
intensity: pair[0].intensity,
|
||||
inverse_of: null,
|
||||
question_type: generatedQuestionType,
|
||||
config: null,
|
||||
}]
|
||||
|
||||
);
|
||||
if (error) throw error;
|
||||
}
|
||||
@@ -440,7 +516,7 @@ export function QuestionsPage() {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('questions')
|
||||
.select('id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, created_at')
|
||||
.select('id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, question_type, created_at')
|
||||
.eq('pack_id', packId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
@@ -589,6 +665,45 @@ export function QuestionsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="question_type">Question Type</Label>
|
||||
<Select
|
||||
value={formData.question_type}
|
||||
onValueChange={(value) => setFormData(d => ({ ...d, question_type: value as QuestionType }))}
|
||||
>
|
||||
<SelectTrigger id="question_type">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{QUESTION_TYPE_OPTIONS.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select how users will answer this question in the app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{formData.question_type === 'audio' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max_duration_seconds">Max Duration (seconds)</Label>
|
||||
<Input
|
||||
id="max_duration_seconds"
|
||||
type="number"
|
||||
min={1}
|
||||
value={formData.max_duration_seconds}
|
||||
onChange={(e) => setFormData(d => ({ ...d, max_duration_seconds: e.target.value }))}
|
||||
placeholder="60"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Audio responses will be limited to this duration.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -772,6 +887,26 @@ export function QuestionsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Label className="text-sm text-muted-foreground">Question Type</Label>
|
||||
<Select
|
||||
value={questionTypeFilter}
|
||||
onValueChange={(value) => setQuestionTypeFilter(value as QuestionType | 'all')}
|
||||
>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
{QUESTION_TYPE_OPTIONS.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Bulk Action Bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center justify-between bg-muted/50 border rounded-lg px-4 py-3">
|
||||
@@ -886,6 +1021,7 @@ export function QuestionsPage() {
|
||||
</TableHead>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Question</TableHead>
|
||||
<TableHead className="w-28">Type</TableHead>
|
||||
<TableHead className="w-20">Inverse</TableHead>
|
||||
<TableHead className="w-32">Partner Text</TableHead>
|
||||
<TableHead className="w-24">Couples</TableHead>
|
||||
@@ -913,6 +1049,18 @@ export function QuestionsPage() {
|
||||
<TableCell>
|
||||
<span className="line-clamp-2">{question.text}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Badge variant="secondary" className="text-[10px] uppercase tracking-wide">
|
||||
{getQuestionTypeLabel(question.question_type)}
|
||||
</Badge>
|
||||
{getConfigSummary(question) && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{getConfigSummary(question)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{question.inverse_of ? (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Admin Portal Plan: New Question Types + Category Multi-Content
|
||||
|
||||
## Context
|
||||
- This plan scopes **admin portal** updates needed for the new question types defined in `docs/NEW_QUESTION_TYPES.md`.
|
||||
- Categories now group **multiple content types** (question packs + dare packs), so admin UI should reflect multi-content counts and navigation.
|
||||
|
||||
## Goals
|
||||
- Allow admins to **create, edit, and review** the new question types (`swipe`, `text_answer`, `audio`, `photo`, `who_likely`).
|
||||
- Make type-specific configuration (ex: audio duration) visible and editable.
|
||||
- Surface question type metadata in lists and analytics.
|
||||
- Update category views to reflect multi-content grouping.
|
||||
|
||||
## Admin Changes: Questions Management
|
||||
|
||||
### 1) Data model updates (admin-side types + queries)
|
||||
- Extend admin `Question` interfaces to include:
|
||||
- `question_type` (enum)
|
||||
- `config` (JSON)
|
||||
- Ensure queries (list + edit + review) select `question_type` + `config`.
|
||||
|
||||
### 2) Question create/edit form
|
||||
- Add **Question Type** selector with default `swipe`.
|
||||
- Show **type-specific fields** below the selector:
|
||||
- `text_answer`: no extra config required (optional helper text).
|
||||
- `audio`: `max_duration_seconds` (default 60) stored in `config`.
|
||||
- `photo`: no extra config required (optional helper text).
|
||||
- `who_likely`: no extra config required (optional helper text).
|
||||
- Persist `question_type` and `config` on create/update.
|
||||
- Confirm which fields still apply to all types (intensity, partner text, props, targeting).
|
||||
|
||||
### 3) Question list + bulk actions
|
||||
- Add a **Question Type** column with badge styling.
|
||||
- Add filters for `question_type` (multi-select).
|
||||
- Show `config` summaries where meaningful (ex: audio duration).
|
||||
- Ensure bulk delete / selection still works with new columns.
|
||||
|
||||
### 4) AI generation + review flows
|
||||
- **AI Generator**: add a question type selector (or keep `swipe` default and set `question_type` accordingly).
|
||||
- **Review Questions dialog**:
|
||||
- Show question type in the review table.
|
||||
- Skip or adapt suggestions for non-swipe types (avoid AI suggesting “partner text” for audio/photo).
|
||||
|
||||
### 5) Question analytics
|
||||
- Segment analytics by `question_type`.
|
||||
- Add per-type counts in summary cards and charts (if present).
|
||||
|
||||
## Admin Changes: Category Multi-Content Grouping
|
||||
|
||||
### 1) Categories page
|
||||
- Replace single `pack_count` with **multi-content counts**:
|
||||
- `question_pack_count`
|
||||
- `dare_pack_count`
|
||||
- Update the category card UI to show both counts.
|
||||
- Provide **two entry points** from each category:
|
||||
- View Question Packs
|
||||
- View Dare Packs
|
||||
|
||||
### 2) Pack list pages
|
||||
- Ensure both `PacksPage` and `DarePacksPage` can be scoped by category.
|
||||
- Keep category filters consistent between both pages.
|
||||
- Consider a shared category “detail” header showing both counts.
|
||||
|
||||
## Dependencies / Open Questions
|
||||
- Confirm whether `intensity` is required for non-swipe question types.
|
||||
- Decide whether `partner_text` applies to `who_likely` or should be hidden.
|
||||
- Align on any additional `config` fields beyond audio duration.
|
||||
|
||||
## QA Checklist
|
||||
- Create each new question type in admin and confirm persistence.
|
||||
- Verify question list filtering by type.
|
||||
- Validate category cards show both pack counts and route correctly.
|
||||
- Confirm analytics breakdown renders by question type.
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: 'expo',
|
||||
ignorePatterns: ['dist/*'],
|
||||
};
|
||||
@@ -10,8 +10,9 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/"
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\""
|
||||
},
|
||||
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user