Enable admin question types and category grouping

Keep shared linting configured for the repo.
This commit is contained in:
jjbrunton committed 2026-01-25 11:53:42 +00:00
1 parent 6c2e471d47
commit 8612b16b93
9 files changed
+415 -19

No files matched your search

+8
View File
@@ -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) {
+43 -6
View File
@@ -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>
+151 -3
View File
@@ -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">
+72
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
root: true,
extends: 'expo',
ignorePatterns: ['dist/*'],
};
+2 -1
View File
@@ -10,8 +10,9 @@
},
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint src/"
"lint": "eslint \"src/**/*.{ts,tsx}\""
},
"devDependencies": {
"typescript": "^5.3.3"
}