feat(admin): manage quiz questions and dare intensity

Add a Quiz Questions content page (list/filter/create/edit/activate-
deactivate, no delete since quiz_answers reference questions by id),
route it under manage_questions, and add a sidebar entry.

Fix DaresPage so intensity (1-5) is editable and shown in the table;
previously every admin-created dare silently defaulted to intensity 1.

Add authored min/max intensity fields to DarePacksPage: no trigger or
worker in apps/api computes these columns, so they are admin-set
thresholds gating pack visibility, not derived aggregates. avg_intensity
is displayed read-only since nothing writes to it and it is not used
for filtering.

Extract dare intensity and quiz option validation into small lib
modules with focused unit tests.
This commit is contained in:
jjbrunton committed 2026-08-30 19:13:08 +01:00
1 parent 25b7c6238e
commit eab73e6314
10 files changed
+771 -1

No files matched your search

+9
View File
@@ -11,6 +11,7 @@ import { QuestionAnalyticsPage } from '@/pages/content/QuestionAnalyticsPage';
import { TagsPage } from '@/pages/content/TagsPage';
import { DarePacksPage } from '@/pages/content/DarePacksPage';
import { DaresPage } from '@/pages/content/DaresPage';
import { QuizQuestionsPage } from '@/pages/content/QuizQuestionsPage';
import { UsersPage } from '@/pages/users/UsersPage';
import { UserDetailPage } from '@/pages/users/UserDetailPage';
import { MatchChatPage } from '@/pages/users/MatchChatPage';
@@ -108,6 +109,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/quiz-questions"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
<QuizQuestionsPage />
</ProtectedRoute>
}
/>
{/* User management */}
<Route
@@ -21,6 +21,7 @@ import {
PieChart,
Tags,
Zap,
HelpCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
@@ -68,6 +69,12 @@ const contentNav: NavItem[] = [
icon: <Tags className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_PACKS,
},
{
label: 'Quiz Questions',
href: '/quiz-questions',
icon: <HelpCircle className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_QUESTIONS,
},
];
const usersNav: NavItem[] = [
@@ -7,6 +7,7 @@ const AUDITED_TABLES = [
'categories',
'dare_packs',
'dares',
'quiz_questions',
'profiles',
'couples',
'responses',
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { clampDareIntensity, parseIntensityRange } from './dareIntensity';
describe('clampDareIntensity', () => {
it('accepts values within the 1-5 range', () => {
expect(clampDareIntensity('1')).toBe(1);
expect(clampDareIntensity('3')).toBe(3);
expect(clampDareIntensity('5')).toBe(5);
});
it('falls back to 1 for missing, non-numeric, or out-of-range values', () => {
expect(clampDareIntensity('')).toBe(1);
expect(clampDareIntensity('abc')).toBe(1);
expect(clampDareIntensity('0')).toBe(1);
expect(clampDareIntensity('6')).toBe(1);
});
it('falls back to a custom default when provided', () => {
expect(clampDareIntensity('', 3)).toBe(3);
expect(clampDareIntensity('9', 3)).toBe(3);
});
});
describe('parseIntensityRange', () => {
it('allows both bounds blank', () => {
expect(parseIntensityRange('', '')).toEqual({ range: { min: null, max: null }, error: null });
});
it('parses valid bounds', () => {
expect(parseIntensityRange('2', '4')).toEqual({ range: { min: 2, max: 4 }, error: null });
});
it('rejects a minimum outside 1-5', () => {
const { error } = parseIntensityRange('0', '');
expect(error).toMatch(/Minimum intensity/);
});
it('rejects a maximum outside 1-5', () => {
const { error } = parseIntensityRange('', '6');
expect(error).toMatch(/Maximum intensity/);
});
it('rejects a minimum greater than the maximum', () => {
const { error } = parseIntensityRange('4', '2');
expect(error).toMatch(/cannot be greater than/);
});
});
+44
View File
@@ -0,0 +1,44 @@
// Dares and dare packs both gate on a 1-5 intensity scale (see
// apps/api/drizzle/0010_admin.sql and 0013_dares_loop.sql). These helpers keep
// the admin form validation consistent with that range.
export const MIN_DARE_INTENSITY = 1;
export const MAX_DARE_INTENSITY = 5;
/**
* Clamp a raw form value to a valid dare intensity (1-5), falling back to 1
* when the value is missing or not a finite number in range.
*/
export function clampDareIntensity(rawValue: string, fallback: number = MIN_DARE_INTENSITY): number {
const parsed = parseInt(rawValue, 10);
if (!Number.isFinite(parsed) || parsed < MIN_DARE_INTENSITY || parsed > MAX_DARE_INTENSITY) {
return fallback;
}
return parsed;
}
export interface IntensityRange {
min: number | null;
max: number | null;
}
/**
* Parse and validate an optional min/max intensity pair for a dare pack.
* Returns the parsed range, or an error message when the values are invalid.
*/
export function parseIntensityRange(rawMin: string, rawMax: string): { range: IntensityRange; error: string | null } {
const min = rawMin.trim() ? parseInt(rawMin, 10) : null;
const max = rawMax.trim() ? parseInt(rawMax, 10) : null;
if (min !== null && (!Number.isFinite(min) || min < MIN_DARE_INTENSITY || min > MAX_DARE_INTENSITY)) {
return { range: { min: null, max: null }, error: 'Minimum intensity must be between 1 and 5' };
}
if (max !== null && (!Number.isFinite(max) || max < MIN_DARE_INTENSITY || max > MAX_DARE_INTENSITY)) {
return { range: { min: null, max: null }, error: 'Maximum intensity must be between 1 and 5' };
}
if (min !== null && max !== null && min > max) {
return { range: { min: null, max: null }, error: 'Minimum intensity cannot be greater than maximum intensity' };
}
return { range: { min, max }, error: null };
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { sanitizeQuizOptions, validateQuizQuestionForm } from './quizQuestions';
describe('sanitizeQuizOptions', () => {
it('trims and drops empty options', () => {
expect(sanitizeQuizOptions([' A ', '', ' ', 'B'])).toEqual(['A', 'B']);
});
});
describe('validateQuizQuestionForm', () => {
const base = {
prompt_self: 'What is your favourite season?',
prompt_guess: "What is your partner's favourite season?",
options: ['Spring', 'Summer'],
};
it('accepts a valid form', () => {
expect(validateQuizQuestionForm(base)).toBeNull();
});
it('requires the self prompt', () => {
expect(validateQuizQuestionForm({ ...base, prompt_self: ' ' })).toMatch(/about yourself/);
});
it('requires the guess prompt', () => {
expect(validateQuizQuestionForm({ ...base, prompt_guess: '' })).toMatch(/about your partner/);
});
it('requires at least two options', () => {
expect(validateQuizQuestionForm({ ...base, options: ['Only one'] })).toMatch(/At least 2 options/);
});
it('allows at most four options', () => {
expect(validateQuizQuestionForm({ ...base, options: ['A', 'B', 'C', 'D', 'E'] })).toMatch(/At most 4 options/);
});
it('ignores blank options when counting', () => {
expect(validateQuizQuestionForm({ ...base, options: ['Spring', 'Summer', '', ''] })).toBeNull();
});
});
+31
View File
@@ -0,0 +1,31 @@
// Quiz question options are constrained by the database check
// `array_length(options, 1) between 2 and 4` (see
// apps/api/drizzle/0019_quiz.sql). These helpers keep the admin form
// validation consistent with that constraint.
export const MIN_QUIZ_OPTIONS = 2;
export const MAX_QUIZ_OPTIONS = 4;
export interface QuizQuestionFormValues {
prompt_self: string;
prompt_guess: string;
options: string[];
}
/** Trim options and drop any that are empty. */
export function sanitizeQuizOptions(options: string[]): string[] {
return options.map((option) => option.trim()).filter(Boolean);
}
/**
* Validate a quiz question form. Returns an error message, or null when the
* form is valid.
*/
export function validateQuizQuestionForm(form: QuizQuestionFormValues): string | null {
if (!form.prompt_self.trim()) return 'The "about yourself" prompt is required';
if (!form.prompt_guess.trim()) return 'The "about your partner" prompt is required';
const cleanedOptions = sanitizeQuizOptions(form.options);
if (cleanedOptions.length < MIN_QUIZ_OPTIONS) return `At least ${MIN_QUIZ_OPTIONS} options are required`;
if (cleanedOptions.length > MAX_QUIZ_OPTIONS) return `At most ${MAX_QUIZ_OPTIONS} options are allowed`;
return null;
}
@@ -33,6 +33,7 @@ import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, ChevronUp, Chev
import { toast } from 'sonner';
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
import type { ContentReviewStatus } from '@sauci/shared';
import { parseIntensityRange } from '@/lib/dareIntensity';
// =============================================================================
// Types
@@ -54,6 +55,9 @@ interface DarePack {
is_explicit: boolean;
sort_order: number;
category_id: string | null;
min_intensity: number | null;
max_intensity: number | null;
avg_intensity: number | null;
created_at: string | null;
dare_count?: number;
content_status?: ContentReviewStatus | null;
@@ -68,6 +72,8 @@ interface DarePackFormData {
is_public: boolean;
is_explicit: boolean;
category_id: string;
min_intensity: string;
max_intensity: string;
}
// =============================================================================
@@ -96,6 +102,8 @@ export function DarePacksPage() {
is_public: false,
is_explicit: false,
category_id: categoryId ?? '',
min_intensity: '',
max_intensity: '',
},
(pack) => ({
name: pack.name,
@@ -105,6 +113,8 @@ export function DarePacksPage() {
is_public: pack.is_public,
is_explicit: pack.is_explicit,
category_id: pack.category_id || '',
min_intensity: pack.min_intensity != null ? String(pack.min_intensity) : '',
max_intensity: pack.max_intensity != null ? String(pack.max_intensity) : '',
})
);
@@ -209,6 +219,14 @@ export function DarePacksPage() {
return;
}
const { range, error: intensityError } = parseIntensityRange(form.formData.min_intensity, form.formData.max_intensity);
if (intensityError) {
toast.error(intensityError);
return;
}
const parsedMin = range.min;
const parsedMax = range.max;
form.setSaving(true);
try {
if (form.editingItem) {
@@ -220,6 +238,8 @@ export function DarePacksPage() {
is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit,
category_id: form.formData.category_id || null,
min_intensity: parsedMin,
max_intensity: parsedMax,
});
if (error) throw error;
toast.success('Dare pack updated');
@@ -232,6 +252,8 @@ export function DarePacksPage() {
is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit,
category_id: form.formData.category_id || null,
min_intensity: parsedMin,
max_intensity: parsedMax,
sort_order: totalCount,
});
if (error) throw error;
@@ -410,6 +432,36 @@ export function DarePacksPage() {
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="min_intensity">Min Intensity (optional)</Label>
<Input
id="min_intensity"
type="number"
min={1}
max={5}
value={form.formData.min_intensity}
onChange={(e) => form.setField('min_intensity', e.target.value)}
placeholder="1"
/>
</div>
<div className="space-y-2">
<Label htmlFor="max_intensity">Max Intensity (optional)</Label>
<Input
id="max_intensity"
type="number"
min={1}
max={5}
value={form.formData.max_intensity}
onChange={(e) => form.setField('max_intensity', e.target.value)}
placeholder="5"
/>
</div>
</div>
<p className="text-xs text-muted-foreground -mt-2">
Gates whether this pack shows for a couple based on their intensity preference. Leave blank for no gate.
</p>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Premium</Label>
@@ -572,6 +624,20 @@ export function DarePacksPage() {
</CardDescription>
</CardHeader>
<CardContent>
{(pack.min_intensity != null || pack.max_intensity != null || pack.avg_intensity != null) && (
<div className="flex flex-wrap gap-1 mb-2">
{(pack.min_intensity != null || pack.max_intensity != null) && (
<Badge variant="outline" className="text-xs">
Intensity {pack.min_intensity ?? 1}-{pack.max_intensity ?? 5}
</Badge>
)}
{pack.avg_intensity != null && (
<Badge variant="outline" className="text-xs">
Avg {pack.avg_intensity}
</Badge>
)}
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{pack.dare_count} dare{pack.dare_count !== 1 ? 's' : ''}
+39 -1
View File
@@ -39,6 +39,7 @@ import { toast } from 'sonner';
import { IconPreview } from '@/components/ui/icon-picker';
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
import type { ContentReviewStatus } from '@sauci/shared';
import { clampDareIntensity, MAX_DARE_INTENSITY, MIN_DARE_INTENSITY } from '@/lib/dareIntensity';
// =============================================================================
// Types
@@ -102,9 +103,11 @@ export function DaresPage() {
// Form state
const [formData, setFormData] = useState<{
text: string;
intensity: string;
suggested_duration_hours: string;
}>({
text: '',
intensity: '1',
suggested_duration_hours: '',
});
@@ -166,7 +169,7 @@ export function DaresPage() {
const openCreateDialog = () => {
setEditingDare(null);
setFormData({ text: '', suggested_duration_hours: '' });
setFormData({ text: '', intensity: '1', suggested_duration_hours: '' });
setDialogOpen(true);
};
@@ -174,6 +177,7 @@ export function DaresPage() {
setEditingDare(dare);
setFormData({
text: dare.text,
intensity: String(dare.intensity ?? 1),
suggested_duration_hours: dare.suggested_duration_hours?.toString() || '',
});
setDialogOpen(true);
@@ -185,10 +189,13 @@ export function DaresPage() {
return;
}
const intensity = clampDareIntensity(formData.intensity);
setSaving(true);
try {
const dareData = {
text: formData.text,
intensity,
suggested_duration_hours: formData.suggested_duration_hours
? parseInt(formData.suggested_duration_hours, 10)
: null,
@@ -338,6 +345,31 @@ export function DaresPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="intensity">Intensity</Label>
<Select
value={formData.intensity}
onValueChange={(value) => setFormData(d => ({ ...d, intensity: value }))}
>
<SelectTrigger id="intensity">
<SelectValue placeholder="Select an intensity" />
</SelectTrigger>
<SelectContent>
{Array.from(
{ length: MAX_DARE_INTENSITY - MIN_DARE_INTENSITY + 1 },
(_, i) => MIN_DARE_INTENSITY + i,
).map((level) => (
<SelectItem key={level} value={String(level)}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
1 is mild, 5 is the most intense. Used to gate dares against a couple's intensity preference.
</p>
</div>
<div className="space-y-2">
<Label>Suggested Duration</Label>
<Select
@@ -444,6 +476,7 @@ export function DaresPage() {
<TableHead className="w-12">#</TableHead>
<TableHead>Dare</TableHead>
<TableHead className="w-40">Catalogue</TableHead>
<TableHead className="w-20">Intensity</TableHead>
<TableHead className="w-24">Duration</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
@@ -478,6 +511,11 @@ export function DaresPage() {
compact
/>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{dare.intensity}
</Badge>
</TableCell>
<TableCell>
{dare.suggested_duration_hours ? (
<Badge variant="outline" className="text-xs">
@@ -0,0 +1,487 @@
import { useState, useEffect, useCallback } from 'react';
import { adminData } from '@/lib/adminApi';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { useEntityForm } from '@/hooks/useEntityForm';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card } from '@/components/ui/card';
import { Plus, Pencil, Loader2, HelpCircle, X, Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import { MAX_QUIZ_OPTIONS, MIN_QUIZ_OPTIONS, sanitizeQuizOptions, validateQuizQuestionForm } from '@/lib/quizQuestions';
// =============================================================================
// Types
// =============================================================================
interface QuizQuestion {
id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
sort_order: number;
is_active: boolean;
created_at: string | null;
}
interface QuizQuestionFormData {
prompt_self: string;
prompt_guess: string;
options: string[];
sort_order: string;
is_active: boolean;
}
type ActiveFilter = 'all' | 'active' | 'inactive';
const emptyOptions = () => ['', ''];
// =============================================================================
// Component
// =============================================================================
export function QuizQuestionsPage() {
const [questions, setQuestions] = useState<QuizQuestion[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
const [activeFilter, setActiveFilter] = useState<ActiveFilter>('all');
const form = useEntityForm<QuizQuestionFormData, QuizQuestion>(
{
prompt_self: '',
prompt_guess: '',
options: emptyOptions(),
sort_order: '0',
is_active: true,
},
(question) => ({
prompt_self: question.prompt_self,
prompt_guess: question.prompt_guess,
options: question.options.length ? [...question.options] : emptyOptions(),
sort_order: String(question.sort_order),
is_active: question.is_active,
})
);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
let query = adminData
.from('quiz_questions')
.select('*', { count: 'exact' })
.order('sort_order', { ascending: true })
.range(from, to);
if (activeFilter === 'active') {
query = query.eq('is_active', true);
} else if (activeFilter === 'inactive') {
query = query.eq('is_active', false);
}
const { data, error, count } = await query;
if (error) throw error;
setTotalCount(count || 0);
setQuestions((data as QuizQuestion[]) || []);
} catch (error) {
toast.error('Failed to load quiz questions');
console.error(error);
} finally {
setLoading(false);
}
}, [page, pageSize, activeFilter]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
useEffect(() => {
setPage(1);
}, [activeFilter]);
// =============================================================================
// Option helpers
// =============================================================================
const setOption = (index: number, value: string) => {
form.setFields({
options: form.formData.options.map((opt, i) => (i === index ? value : opt)),
});
};
const addOption = () => {
if (form.formData.options.length >= MAX_QUIZ_OPTIONS) return;
form.setFields({ options: [...form.formData.options, ''] });
};
const removeOption = (index: number) => {
if (form.formData.options.length <= MIN_QUIZ_OPTIONS) return;
form.setFields({ options: form.formData.options.filter((_, i) => i !== index) });
};
// =============================================================================
// Handlers
// =============================================================================
const handleSave = async () => {
const validationError = validateQuizQuestionForm(form.formData);
if (validationError) {
toast.error(validationError);
return;
}
const cleanedOptions = sanitizeQuizOptions(form.formData.options);
const parsedSortOrder = Number(form.formData.sort_order);
const sortOrder = Number.isFinite(parsedSortOrder) ? parsedSortOrder : 0;
const payload = {
prompt_self: form.formData.prompt_self.trim(),
prompt_guess: form.formData.prompt_guess.trim(),
options: cleanedOptions,
sort_order: sortOrder,
is_active: form.formData.is_active,
};
form.setSaving(true);
try {
if (form.editingItem) {
const { error } = await auditedAdminData.update('quiz_questions', form.editingItem.id, payload);
if (error) throw error;
toast.success('Quiz question updated');
} else {
const { error } = await auditedAdminData.insert('quiz_questions', payload);
if (error) throw error;
toast.success('Quiz question created');
}
form.close();
fetchData();
} catch (error) {
toast.error('Failed to save quiz question');
console.error(error);
} finally {
form.setSaving(false);
}
};
const handleToggleActive = async (question: QuizQuestion) => {
try {
const { error } = await auditedAdminData.update('quiz_questions', question.id, {
is_active: !question.is_active,
});
if (error) throw error;
toast.success(question.is_active ? 'Quiz question deactivated' : 'Quiz question activated');
fetchData();
} catch (error) {
toast.error('Failed to update quiz question');
console.error(error);
}
};
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-10 w-40" />
</div>
<Skeleton className="h-96" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<HelpCircle className="h-8 w-8 text-purple-500" />
Quiz Questions
</h1>
<p className="text-muted-foreground">
{totalCount} question{totalCount !== 1 ? 's' : ''} in the couples quiz pool
</p>
</div>
<Dialog open={form.dialogOpen} onOpenChange={form.setDialogOpen}>
<DialogTrigger asChild>
<Button onClick={form.openCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Question
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{form.isEditing ? 'Edit Quiz Question' : 'Add Quiz Question'}
</DialogTitle>
<DialogDescription>
{form.isEditing
? 'Update the quiz question details below.'
: 'Add a new question to the couples quiz pool.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="prompt_self">Prompt (about yourself)</Label>
<Textarea
id="prompt_self"
value={form.formData.prompt_self}
onChange={(e) => form.setField('prompt_self', e.target.value)}
placeholder="e.g., What is your idea of a perfect date night?"
rows={2}
/>
<p className="text-xs text-muted-foreground">
Shown when a partner answers about themselves.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="prompt_guess">Prompt (guessing about partner)</Label>
<Textarea
id="prompt_guess"
value={form.formData.prompt_guess}
onChange={(e) => form.setField('prompt_guess', e.target.value)}
placeholder="e.g., What is your partner's idea of a perfect date night?"
rows={2}
/>
<p className="text-xs text-muted-foreground">
Shown when a partner guesses the other partner's answer. Same options, different phrasing.
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Options ({MIN_QUIZ_OPTIONS}-{MAX_QUIZ_OPTIONS})</Label>
{form.formData.options.length < MAX_QUIZ_OPTIONS && (
<Button type="button" variant="outline" size="sm" onClick={addOption}>
<Plus className="mr-1 h-3 w-3" />
Add Option
</Button>
)}
</div>
<div className="space-y-2">
{form.formData.options.map((option, index) => (
<div key={index} className="flex items-center gap-2">
<Input
value={option}
onChange={(e) => setOption(index, e.target.value)}
placeholder={`Option ${index + 1}`}
/>
{form.formData.options.length > MIN_QUIZ_OPTIONS && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeOption(index)}
aria-label={`Remove option ${index + 1}`}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Both partners choose from the same list of options.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="sort_order">Sort Order</Label>
<Input
id="sort_order"
type="number"
value={form.formData.sort_order}
onChange={(e) => form.setField('sort_order', e.target.value)}
placeholder="0"
/>
<p className="text-xs text-muted-foreground">
Lower numbers are considered before higher numbers when questions have equal usage history.
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Active</Label>
<p className="text-xs text-muted-foreground">
Inactive questions are never selected for a new quiz session.
</p>
</div>
<Switch
checked={form.formData.is_active}
onCheckedChange={(checked) => form.setField('is_active', checked)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={form.close}>
Cancel
</Button>
<Button onClick={handleSave} disabled={form.saving}>
{form.saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="flex flex-wrap items-center gap-3">
<Label className="text-sm text-muted-foreground">Status</Label>
<Select value={activeFilter} onValueChange={(value) => setActiveFilter(value as ActiveFilter)}>
<SelectTrigger className="w-48">
<SelectValue placeholder="All questions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All questions</SelectItem>
<SelectItem value="active">Active only</SelectItem>
<SelectItem value="inactive">Inactive only</SelectItem>
</SelectContent>
</Select>
</div>
{/* Quiz Questions Table */}
{questions.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
<HelpCircle className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">No quiz questions yet</h3>
<p className="text-muted-foreground mb-4">
Add questions to build the couples quiz pool
</p>
<Button onClick={form.openCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Question
</Button>
</Card>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Order</TableHead>
<TableHead>Prompt</TableHead>
<TableHead>Options</TableHead>
<TableHead className="w-24">Status</TableHead>
<TableHead className="w-32">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{questions.map((question) => (
<TableRow key={question.id}>
<TableCell className="font-medium text-muted-foreground">
{question.sort_order}
</TableCell>
<TableCell>
<p className="line-clamp-2 font-medium">{question.prompt_self}</p>
<p className="line-clamp-1 text-xs text-muted-foreground">{question.prompt_guess}</p>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{question.options.map((option, i) => (
<Badge key={i} variant="outline" className="text-xs">
{option}
</Badge>
))}
</div>
</TableCell>
<TableCell>
<Badge variant={question.is_active ? 'default' : 'secondary'}>
{question.is_active ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => form.openEdit(question)}
aria-label="Edit question"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleToggleActive(question)}
aria-label={question.is_active ? 'Deactivate question' : 'Activate question'}
title={question.is_active ? 'Deactivate' : 'Activate'}
>
{question.is_active ? (
<EyeOff className="h-4 w-4 text-destructive" />
) : (
<Eye className="h-4 w-4 text-green-500" />
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{questions.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div>
);
}