diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 64ca1c0..e5a8030 100755 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -60,6 +60,14 @@ function App() { } /> + + + + } + /> = { + 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 */}
{suggestion.question.text}
+
+ + {QUESTION_TYPE_LABELS[suggestion.question.question_type ?? 'swipe']} + +
{suggestion.question.partner_text && (
Partner: {suggestion.question.partner_text} diff --git a/apps/admin/src/pages/content/CategoriesPage.tsx b/apps/admin/src/pages/content/CategoriesPage.tsx index b83f232..070c9ad 100644 --- a/apps/admin/src/pages/content/CategoriesPage.tsx +++ b/apps/admin/src/pages/content/CategoriesPage.tsx @@ -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 }
- - {category.pack_count} pack{category.pack_count !== 1 ? 's' : ''} - +
+ + {category.question_pack_count} question pack{category.question_pack_count !== 1 ? 's' : ''} + + + {category.dare_pack_count} dare pack{category.dare_pack_count !== 1 ? 's' : ''} + +
- - - +
+ + + + + + +
); @@ -213,17 +226,30 @@ export function CategoriesPage() { setTotalCount(count || 0); const categoryIds = (cats || []).map(c => c.id); - const packCounts: Record = {}; + const questionPackCounts: Record = {}; + const darePackCounts: Record = {}; if (categoryIds.length > 0) { - const { data: packs } = await supabase - .from('question_packs') - .select('category_id') - .in('category_id', categoryIds); + const [{ data: packs }, { data: darePacks }] = await Promise.all([ + supabase + .from('question_packs') + .select('category_id') + .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) { diff --git a/apps/admin/src/pages/content/DarePacksPage.tsx b/apps/admin/src/pages/content/DarePacksPage.tsx index 816be78..1aa55e7 100644 --- a/apps/admin/src/pages/content/DarePacksPage.tsx +++ b/apps/admin/src/pages/content/DarePacksPage.tsx @@ -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([]); const [categories, setCategories] = useState([]); + const [category, setCategory] = useState(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() {

- - Dare Packs + {category ? ( + <> + + {category.name} + + ) : ( + <> + + Dare Packs + + )}

- 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'}

- diff --git a/apps/admin/src/pages/content/QuestionAnalyticsPage.tsx b/apps/admin/src/pages/content/QuestionAnalyticsPage.tsx index 4c6c604..73dd597 100644 --- a/apps/admin/src/pages/content/QuestionAnalyticsPage.tsx +++ b/apps/admin/src/pages/content/QuestionAnalyticsPage.tsx @@ -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 = { + 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([]); + const [questionTypeBreakdown, setQuestionTypeBreakdown] = useState([]); const [intensityBreakdown, setIntensityBreakdown] = useState>({ 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 = { all: 0, premium: 0, free: 0 }; + const questionTypeCounts: Record = { + swipe: 0, + text_answer: 0, + audio: 0, + photo: 0, + who_likely: 0, + }; const categoryCountsBySegment: Record> = { 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() {
+ + + Question Types + + Distribution of questions by answer format + + + + {loading ? ( + + ) : stats.totalQuestions === 0 ? ( +
+ No question data available yet +
+ ) : ( +
+ + + + `${value}`} + /> + + + {questionTypeBreakdown.map((entry, index) => ( + + ))} + + + +
+ )} +
+
+
diff --git a/apps/admin/src/pages/content/QuestionsPage.tsx b/apps/admin/src/pages/content/QuestionsPage.tsx index ca10ad5..2336b02 100644 --- a/apps/admin/src/pages/content/QuestionsPage.tsx +++ b/apps/admin/src/pages/content/QuestionsPage.tsx @@ -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(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('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({ @@ -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; @@ -410,12 +483,15 @@ export function QuestionsPage() { const { error } = await auditedSupabase.insert( 'questions', [{ - pack_id: packId, - text: pair[0].text, - partner_text: pair[0].partner_text || null, - intensity: pair[0].intensity, - inverse_of: null, - }] + pack_id: packId, + text: pair[0].text, + 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() { />
+
+ + +

+ Select how users will answer this question in the app. +

+
+ + {formData.question_type === 'audio' && ( +
+ + setFormData(d => ({ ...d, max_duration_seconds: e.target.value }))} + placeholder="60" + /> +

+ Audio responses will be limited to this duration. +

+
+ )} +
@@ -772,6 +887,26 @@ export function QuestionsPage() {
+
+ + +
+ {/* Bulk Action Bar */} {selectedIds.size > 0 && (
@@ -886,6 +1021,7 @@ export function QuestionsPage() { # Question + Type Inverse Partner Text Couples @@ -913,6 +1049,18 @@ export function QuestionsPage() { {question.text} + +
+ + {getQuestionTypeLabel(question.question_type)} + + {getConfigSummary(question) && ( +

+ {getConfigSummary(question)} +

+ )} +
+
{question.inverse_of ? (
diff --git a/docs/admin-question-types-plan.md b/docs/admin-question-types-plan.md new file mode 100644 index 0000000..ecaed88 --- /dev/null +++ b/docs/admin-question-types-plan.md @@ -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. diff --git a/packages/shared/.eslintrc.js b/packages/shared/.eslintrc.js new file mode 100644 index 0000000..6a16ebe --- /dev/null +++ b/packages/shared/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = { + root: true, + extends: 'expo', + ignorePatterns: ['dist/*'], +}; diff --git a/packages/shared/package.json b/packages/shared/package.json index b3bbf1f..1357574 100755 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -8,10 +8,11 @@ ".": "./src/index.ts", "./types": "./src/types/index.ts" }, - "scripts": { - "typecheck": "tsc --noEmit", - "lint": "eslint src/" - }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint \"src/**/*.{ts,tsx}\"" + }, + "devDependencies": { "typescript": "^5.3.3" }