mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18: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
+434
-38
No files matched your search
@@ -60,6 +60,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/categories/:categoryId/dare-packs"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_PACKS}>
|
||||||
|
<DarePacksPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/packs/:packId/questions"
|
path="/packs/:packId/questions"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ import { supabase } from '@/config';
|
|||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||||
import { toast } from 'sonner';
|
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 {
|
interface Question {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -34,6 +44,7 @@ interface Question {
|
|||||||
allowed_couple_genders: string[] | null;
|
allowed_couple_genders: string[] | null;
|
||||||
target_user_genders: string[] | null;
|
target_user_genders: string[] | null;
|
||||||
required_props?: string[] | null;
|
required_props?: string[] | null;
|
||||||
|
question_type?: QuestionType | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReviewQuestionsDialogProps {
|
interface ReviewQuestionsDialogProps {
|
||||||
@@ -601,6 +612,11 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
|||||||
{/* Question Column */}
|
{/* Question Column */}
|
||||||
<TableCell className="align-top py-3">
|
<TableCell className="align-top py-3">
|
||||||
<div className="text-sm font-medium">{suggestion.question.text}</div>
|
<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 && (
|
{suggestion.question.partner_text && (
|
||||||
<div className="text-xs text-muted-foreground mt-1.5 italic">
|
<div className="text-xs text-muted-foreground mt-1.5 italic">
|
||||||
Partner: {suggestion.question.partner_text}
|
Partner: {suggestion.question.partner_text}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ interface Category {
|
|||||||
sort_order: number | null;
|
sort_order: number | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
pack_count?: number;
|
question_pack_count?: number;
|
||||||
|
dare_pack_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CategoryFormData {
|
interface CategoryFormData {
|
||||||
@@ -134,9 +135,14 @@ function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between mt-auto">
|
<div className="flex items-center justify-between mt-auto">
|
||||||
<Badge variant="secondary" className="font-normal">
|
<div className="flex flex-wrap gap-2">
|
||||||
{category.pack_count} pack{category.pack_count !== 1 ? 's' : ''}
|
<Badge variant="secondary" className="font-normal">
|
||||||
</Badge>
|
{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">
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<Button
|
<Button
|
||||||
@@ -158,11 +164,18 @@ function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link to={`/categories/${category.id}/packs`}>
|
<div className="grid gap-2">
|
||||||
<Button variant="outline" className="w-full">
|
<Link to={`/categories/${category.id}/packs`}>
|
||||||
View Packs
|
<Button variant="outline" className="w-full">
|
||||||
</Button>
|
View Question Packs
|
||||||
</Link>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to={`/categories/${category.id}/dare-packs`}>
|
||||||
|
<Button variant="outline" className="w-full">
|
||||||
|
View Dare Packs
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -213,17 +226,30 @@ export function CategoriesPage() {
|
|||||||
setTotalCount(count || 0);
|
setTotalCount(count || 0);
|
||||||
|
|
||||||
const categoryIds = (cats || []).map(c => c.id);
|
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) {
|
if (categoryIds.length > 0) {
|
||||||
const { data: packs } = await supabase
|
const [{ data: packs }, { data: darePacks }] = await Promise.all([
|
||||||
.from('question_packs')
|
supabase
|
||||||
.select('category_id')
|
.from('question_packs')
|
||||||
.in('category_id', categoryIds);
|
.select('category_id')
|
||||||
|
.in('category_id', categoryIds),
|
||||||
|
supabase
|
||||||
|
.from('dare_packs')
|
||||||
|
.select('category_id')
|
||||||
|
.in('category_id', categoryIds),
|
||||||
|
]);
|
||||||
|
|
||||||
packs?.forEach(p => {
|
packs?.forEach(p => {
|
||||||
if (p.category_id) {
|
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(
|
setCategories(
|
||||||
(cats || []).map(c => ({
|
(cats || []).map(c => ({
|
||||||
...c,
|
...c,
|
||||||
pack_count: packCounts[c.id] || 0,
|
question_pack_count: questionPackCounts[c.id] || 0,
|
||||||
|
dare_pack_count: darePackCounts[c.id] || 0,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { supabase } from '@/config';
|
import { supabase } from '@/config';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||||
@@ -39,6 +39,7 @@ import { toast } from 'sonner';
|
|||||||
interface Category {
|
interface Category {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DarePack {
|
interface DarePack {
|
||||||
@@ -73,9 +74,12 @@ interface DarePackFormData {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export function DarePacksPage() {
|
export function DarePacksPage() {
|
||||||
|
const { categoryId } = useParams<{ categoryId: string }>();
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
const [darePacks, setDarePacks] = useState<DarePack[]>([]);
|
const [darePacks, setDarePacks] = useState<DarePack[]>([]);
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [category, setCategory] = useState<Category | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(12);
|
const [pageSize, setPageSize] = useState(12);
|
||||||
@@ -90,7 +94,7 @@ export function DarePacksPage() {
|
|||||||
is_premium: false,
|
is_premium: false,
|
||||||
is_public: false,
|
is_public: false,
|
||||||
is_explicit: false,
|
is_explicit: false,
|
||||||
category_id: '',
|
category_id: categoryId ?? '',
|
||||||
},
|
},
|
||||||
(pack) => ({
|
(pack) => ({
|
||||||
name: pack.name,
|
name: pack.name,
|
||||||
@@ -113,13 +117,31 @@ export function DarePacksPage() {
|
|||||||
const from = (page - 1) * pageSize;
|
const from = (page - 1) * pageSize;
|
||||||
const to = from + pageSize - 1;
|
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
|
// Fetch dare packs
|
||||||
const { data: packData, error, count } = await supabase
|
let query = supabase
|
||||||
.from('dare_packs')
|
.from('dare_packs')
|
||||||
.select('*', { count: 'exact' })
|
.select('*', { count: 'exact' })
|
||||||
.order('sort_order', { ascending: true })
|
.order('sort_order', { ascending: true })
|
||||||
.range(from, to);
|
.range(from, to);
|
||||||
|
|
||||||
|
if (categoryId) {
|
||||||
|
query = query.eq('category_id', categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: packData, error, count } = await query;
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
setTotalCount(count || 0);
|
setTotalCount(count || 0);
|
||||||
@@ -159,12 +181,16 @@ export function DarePacksPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, pageSize]);
|
}, [page, pageSize, categoryId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [categoryId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
if (page > totalPages) {
|
if (page > totalPages) {
|
||||||
@@ -305,16 +331,27 @@ export function DarePacksPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
<Zap className="h-8 w-8 text-orange-500" />
|
{category ? (
|
||||||
Dare Packs
|
<>
|
||||||
|
<IconPreview value={category.icon} fallback="flame-outline" className="text-3xl" />
|
||||||
|
{category.name}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap className="h-8 w-8 text-orange-500" />
|
||||||
|
Dare Packs
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={form.dialogOpen} onOpenChange={form.setDialogOpen}>
|
<Dialog open={form.dialogOpen} onOpenChange={form.setDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button onClick={form.openCreate}>
|
<Button onClick={() => form.openCreateWith({ category_id: categoryId ?? '' })}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Dare Pack
|
Add Dare Pack
|
||||||
</Button>
|
</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 { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Cell, Pie, PieChart as RechartsPieChart } from 'recharts';
|
||||||
import { Crown, MessageCircle, PieChart, Sparkles, Tag, Eye, EyeOff } from 'lucide-react';
|
import { Crown, MessageCircle, PieChart, Sparkles, Tag, Eye, EyeOff } from 'lucide-react';
|
||||||
|
|
||||||
|
type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
|
||||||
|
|
||||||
interface QuestionRow {
|
interface QuestionRow {
|
||||||
id: string;
|
id: string;
|
||||||
pack_id: string;
|
pack_id: string;
|
||||||
intensity: number | null;
|
intensity: number | null;
|
||||||
partner_text: string | null;
|
partner_text: string | null;
|
||||||
inverse_of?: string | null;
|
inverse_of?: string | null;
|
||||||
|
question_type?: QuestionType | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PackRow {
|
interface PackRow {
|
||||||
@@ -62,6 +65,14 @@ type SegmentKey = 'all' | 'premium' | 'free';
|
|||||||
const intensityPalette = ['#22c55e', '#84cc16', '#facc15', '#f97316', '#ef4444'];
|
const intensityPalette = ['#22c55e', '#84cc16', '#facc15', '#f97316', '#ef4444'];
|
||||||
const intensityLabels = ['Gentle', 'Warm', 'Playful', 'Steamy', 'Intense'];
|
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 formatPercent = (count: number, total: number) => (total > 0 ? Math.round((count / total) * 100) : 0);
|
||||||
|
|
||||||
const getCategoryName = (category?: PackRow['category']) => {
|
const getCategoryName = (category?: PackRow['category']) => {
|
||||||
@@ -89,6 +100,7 @@ export function QuestionAnalyticsPage() {
|
|||||||
taggedQuestions: 0,
|
taggedQuestions: 0,
|
||||||
});
|
});
|
||||||
const [premiumBreakdown, setPremiumBreakdown] = useState<ChartDatum[]>([]);
|
const [premiumBreakdown, setPremiumBreakdown] = useState<ChartDatum[]>([]);
|
||||||
|
const [questionTypeBreakdown, setQuestionTypeBreakdown] = useState<ChartDatum[]>([]);
|
||||||
const [intensityBreakdown, setIntensityBreakdown] = useState<Record<SegmentKey, ChartDatum[]>>({
|
const [intensityBreakdown, setIntensityBreakdown] = useState<Record<SegmentKey, ChartDatum[]>>({
|
||||||
all: [],
|
all: [],
|
||||||
premium: [],
|
premium: [],
|
||||||
@@ -125,7 +137,7 @@ export function QuestionAnalyticsPage() {
|
|||||||
const to = from + pageSize - 1;
|
const to = from + pageSize - 1;
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('questions')
|
.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);
|
.range(from, to);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -210,6 +222,13 @@ export function QuestionAnalyticsPage() {
|
|||||||
free: { ...emptyIntensityCounts },
|
free: { ...emptyIntensityCounts },
|
||||||
};
|
};
|
||||||
const untaggedCounts: Record<SegmentKey, number> = { all: 0, premium: 0, free: 0 };
|
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>> = {
|
const categoryCountsBySegment: Record<SegmentKey, Map<string, BreakdownItem>> = {
|
||||||
all: new Map(),
|
all: new Map(),
|
||||||
premium: new Map(),
|
premium: new Map(),
|
||||||
@@ -233,6 +252,9 @@ export function QuestionAnalyticsPage() {
|
|||||||
const segmentKey: SegmentKey = isPremium ? 'premium' : 'free';
|
const segmentKey: SegmentKey = isPremium ? 'premium' : 'free';
|
||||||
const categoryName = getCategoryName(pack?.category);
|
const categoryName = getCategoryName(pack?.category);
|
||||||
const categoryId = pack?.category_id || 'uncategorized';
|
const categoryId = pack?.category_id || 'uncategorized';
|
||||||
|
const questionType = question.question_type ?? 'swipe';
|
||||||
|
|
||||||
|
questionTypeCounts[questionType] = (questionTypeCounts[questionType] || 0) + 1;
|
||||||
|
|
||||||
if (isPremium) {
|
if (isPremium) {
|
||||||
premiumCount += 1;
|
premiumCount += 1;
|
||||||
@@ -302,6 +324,13 @@ export function QuestionAnalyticsPage() {
|
|||||||
{ name: 'Premium', value: premiumCount, color: '#f59e0b' },
|
{ name: 'Premium', value: premiumCount, color: '#f59e0b' },
|
||||||
{ name: 'Free', value: freeCount, color: '#22c55e' },
|
{ 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) => ({
|
const buildIntensityData = (key: SegmentKey) => intensityLabels.map((label, index) => ({
|
||||||
name: label,
|
name: label,
|
||||||
@@ -355,6 +384,7 @@ export function QuestionAnalyticsPage() {
|
|||||||
taggedQuestions: taggedCount,
|
taggedQuestions: taggedCount,
|
||||||
});
|
});
|
||||||
setPremiumBreakdown(premiumData);
|
setPremiumBreakdown(premiumData);
|
||||||
|
setQuestionTypeBreakdown(questionTypeData);
|
||||||
setIntensityBreakdown(intensityData);
|
setIntensityBreakdown(intensityData);
|
||||||
setTopicBreakdown(topicData);
|
setTopicBreakdown(topicData);
|
||||||
setCategoryBreakdown(categoryData);
|
setCategoryBreakdown(categoryData);
|
||||||
@@ -645,6 +675,58 @@ export function QuestionAnalyticsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ interface QuestionPack {
|
|||||||
is_explicit: boolean;
|
is_explicit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
|
||||||
|
|
||||||
|
interface QuestionConfig {
|
||||||
|
max_duration_seconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface Question {
|
interface Question {
|
||||||
id: string;
|
id: string;
|
||||||
pack_id: string;
|
pack_id: string;
|
||||||
@@ -63,6 +69,8 @@ interface Question {
|
|||||||
required_props?: string[] | null;
|
required_props?: string[] | null;
|
||||||
inverse_of?: string | null;
|
inverse_of?: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
|
question_type?: QuestionType | null;
|
||||||
|
config?: QuestionConfig | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { INTENSITY_LEVELS } from '@/lib/openai';
|
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)
|
// Helper to get intensity info by level (1-indexed)
|
||||||
const getIntensity = (level: number) => INTENSITY_LEVELS[level - 1] || INTENSITY_LEVELS[0];
|
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() {
|
export function QuestionsPage() {
|
||||||
const { packId } = useParams<{ packId: string }>();
|
const { packId } = useParams<{ packId: string }>();
|
||||||
const [pack, setPack] = useState<QuestionPack | null>(null);
|
const [pack, setPack] = useState<QuestionPack | null>(null);
|
||||||
@@ -90,6 +122,7 @@ export function QuestionsPage() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(25);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [questionTypeFilter, setQuestionTypeFilter] = useState<QuestionType | 'all'>('all');
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState<{
|
const [formData, setFormData] = useState<{
|
||||||
@@ -100,6 +133,8 @@ export function QuestionsPage() {
|
|||||||
target_user_genders: string[];
|
target_user_genders: string[];
|
||||||
required_props: string;
|
required_props: string;
|
||||||
inverse_of: string;
|
inverse_of: string;
|
||||||
|
question_type: QuestionType;
|
||||||
|
max_duration_seconds: string;
|
||||||
}>({
|
}>({
|
||||||
text: '',
|
text: '',
|
||||||
partner_text: '',
|
partner_text: '',
|
||||||
@@ -108,6 +143,8 @@ export function QuestionsPage() {
|
|||||||
target_user_genders: [],
|
target_user_genders: [],
|
||||||
required_props: '',
|
required_props: '',
|
||||||
inverse_of: '',
|
inverse_of: '',
|
||||||
|
question_type: 'swipe',
|
||||||
|
max_duration_seconds: String(DEFAULT_AUDIO_DURATION),
|
||||||
});
|
});
|
||||||
|
|
||||||
// All questions in pack for inverse_of dropdown (loaded once)
|
// All questions in pack for inverse_of dropdown (loaded once)
|
||||||
@@ -126,12 +163,18 @@ export function QuestionsPage() {
|
|||||||
.select('id, name, description, icon, is_explicit')
|
.select('id, name, description, icon, is_explicit')
|
||||||
.eq('id', packId)
|
.eq('id', packId)
|
||||||
.single();
|
.single();
|
||||||
const questionsPromise = supabase
|
let questionsQuery = supabase
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('*', { count: 'exact' })
|
.select('*', { count: 'exact' })
|
||||||
.eq('pack_id', packId)
|
.eq('pack_id', packId)
|
||||||
.order('created_at', { ascending: true })
|
.order('created_at', { ascending: true })
|
||||||
.range(from, to);
|
.range(from, to);
|
||||||
|
|
||||||
|
if (questionTypeFilter !== 'all') {
|
||||||
|
questionsQuery = questionsQuery.eq('question_type', questionTypeFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const questionsPromise = questionsQuery;
|
||||||
const allPackPromise = supabase
|
const allPackPromise = supabase
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('id, text, inverse_of')
|
.select('id, text, inverse_of')
|
||||||
@@ -162,7 +205,7 @@ export function QuestionsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [packId, page, pageSize]);
|
}, [packId, page, pageSize, questionTypeFilter]);
|
||||||
|
|
||||||
// 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>({
|
||||||
@@ -203,6 +246,11 @@ export function QuestionsPage() {
|
|||||||
setReviewOpen(false);
|
setReviewOpen(false);
|
||||||
}, [packId]);
|
}, [packId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}, [questionTypeFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
}, [page, pageSize]);
|
}, [page, pageSize]);
|
||||||
@@ -233,6 +281,8 @@ export function QuestionsPage() {
|
|||||||
target_user_genders: [],
|
target_user_genders: [],
|
||||||
required_props: '',
|
required_props: '',
|
||||||
inverse_of: '',
|
inverse_of: '',
|
||||||
|
question_type: 'swipe',
|
||||||
|
max_duration_seconds: String(DEFAULT_AUDIO_DURATION),
|
||||||
});
|
});
|
||||||
await fetchAllPackQuestions();
|
await fetchAllPackQuestions();
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
@@ -248,6 +298,8 @@ export function QuestionsPage() {
|
|||||||
target_user_genders: question.target_user_genders || [],
|
target_user_genders: question.target_user_genders || [],
|
||||||
required_props: question.required_props?.join(', ') ?? '',
|
required_props: question.required_props?.join(', ') ?? '',
|
||||||
inverse_of: question.inverse_of || '',
|
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();
|
await fetchAllPackQuestions();
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
@@ -264,6 +316,13 @@ export function QuestionsPage() {
|
|||||||
.map(value => value.trim())
|
.map(value => value.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const requiredPropsValue = requiredProps.length > 0 ? requiredProps : null;
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -276,6 +335,8 @@ export function QuestionsPage() {
|
|||||||
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
|
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
|
||||||
required_props: requiredPropsValue,
|
required_props: requiredPropsValue,
|
||||||
inverse_of: formData.inverse_of || null,
|
inverse_of: formData.inverse_of || null,
|
||||||
|
question_type: formData.question_type,
|
||||||
|
config: configValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
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,
|
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
|
||||||
required_props: requiredPropsValue,
|
required_props: requiredPropsValue,
|
||||||
inverse_of: formData.inverse_of || null,
|
inverse_of: formData.inverse_of || null,
|
||||||
|
question_type: formData.question_type,
|
||||||
|
config: configValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
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 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
|
// Bulk insert AI-generated questions with inverse_of linking
|
||||||
const insertQuestions = async () => {
|
const insertQuestions = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -351,6 +416,8 @@ export function QuestionsPage() {
|
|||||||
partner_text: q.partner_text || null,
|
partner_text: q.partner_text || null,
|
||||||
intensity: q.intensity,
|
intensity: q.intensity,
|
||||||
inverse_of: null,
|
inverse_of: null,
|
||||||
|
question_type: generatedQuestionType,
|
||||||
|
config: null,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
if (standaloneError) throw standaloneError;
|
if (standaloneError) throw standaloneError;
|
||||||
@@ -369,6 +436,8 @@ export function QuestionsPage() {
|
|||||||
partner_text: primary.partner_text || null,
|
partner_text: primary.partner_text || null,
|
||||||
intensity: primary.intensity,
|
intensity: primary.intensity,
|
||||||
inverse_of: null,
|
inverse_of: null,
|
||||||
|
question_type: generatedQuestionType,
|
||||||
|
config: null,
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
if (primaryError) throw primaryError;
|
if (primaryError) throw primaryError;
|
||||||
@@ -385,6 +454,8 @@ export function QuestionsPage() {
|
|||||||
partner_text: inverse.partner_text || null,
|
partner_text: inverse.partner_text || null,
|
||||||
intensity: inverse.intensity,
|
intensity: inverse.intensity,
|
||||||
inverse_of: primaryId,
|
inverse_of: primaryId,
|
||||||
|
question_type: generatedQuestionType,
|
||||||
|
config: null,
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
if (inverseError) throw inverseError;
|
if (inverseError) throw inverseError;
|
||||||
@@ -401,6 +472,8 @@ export function QuestionsPage() {
|
|||||||
partner_text: extra.partner_text || null,
|
partner_text: extra.partner_text || null,
|
||||||
intensity: extra.intensity,
|
intensity: extra.intensity,
|
||||||
inverse_of: null,
|
inverse_of: null,
|
||||||
|
question_type: generatedQuestionType,
|
||||||
|
config: null,
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
if (extraError) throw extraError;
|
if (extraError) throw extraError;
|
||||||
@@ -410,12 +483,15 @@ export function QuestionsPage() {
|
|||||||
const { error } = await auditedSupabase.insert(
|
const { error } = await auditedSupabase.insert(
|
||||||
'questions',
|
'questions',
|
||||||
[{
|
[{
|
||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
text: pair[0].text,
|
text: pair[0].text,
|
||||||
partner_text: pair[0].partner_text || null,
|
partner_text: pair[0].partner_text || null,
|
||||||
intensity: pair[0].intensity,
|
intensity: pair[0].intensity,
|
||||||
inverse_of: null,
|
inverse_of: null,
|
||||||
}]
|
question_type: generatedQuestionType,
|
||||||
|
config: null,
|
||||||
|
}]
|
||||||
|
|
||||||
);
|
);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
@@ -440,7 +516,7 @@ export function QuestionsPage() {
|
|||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('questions')
|
.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)
|
.eq('pack_id', packId)
|
||||||
.order('created_at', { ascending: true });
|
.order('created_at', { ascending: true });
|
||||||
|
|
||||||
@@ -589,6 +665,45 @@ export function QuestionsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -772,6 +887,26 @@ export function QuestionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Bulk Action Bar */}
|
||||||
{selectedIds.size > 0 && (
|
{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 justify-between bg-muted/50 border rounded-lg px-4 py-3">
|
||||||
@@ -886,6 +1021,7 @@ export function QuestionsPage() {
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-12">#</TableHead>
|
<TableHead className="w-12">#</TableHead>
|
||||||
<TableHead>Question</TableHead>
|
<TableHead>Question</TableHead>
|
||||||
|
<TableHead className="w-28">Type</TableHead>
|
||||||
<TableHead className="w-20">Inverse</TableHead>
|
<TableHead className="w-20">Inverse</TableHead>
|
||||||
<TableHead className="w-32">Partner Text</TableHead>
|
<TableHead className="w-32">Partner Text</TableHead>
|
||||||
<TableHead className="w-24">Couples</TableHead>
|
<TableHead className="w-24">Couples</TableHead>
|
||||||
@@ -913,6 +1049,18 @@ export function QuestionsPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="line-clamp-2">{question.text}</span>
|
<span className="line-clamp-2">{question.text}</span>
|
||||||
</TableCell>
|
</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>
|
<TableCell>
|
||||||
{question.inverse_of ? (
|
{question.inverse_of ? (
|
||||||
<div className="flex items-center gap-2">
|
<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/*'],
|
||||||
|
};
|
||||||
@@ -8,10 +8,11 @@
|
|||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./types": "./src/types/index.ts"
|
"./types": "./src/types/index.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint src/"
|
"lint": "eslint \"src/**/*.{ts,tsx}\""
|
||||||
},
|
},
|
||||||
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user