mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(admin): add NSFW flag to packs, remove intensity
Re-add is_explicit toggle on pack and dare pack forms labeled as "NSFW". Show NSFW badge on pack cards and user enabled packs. Remove all intensity/comfort level references from admin UI including form fields, analytics charts, review dialog columns, AI generation prompts/scoring, cherry-pick balancing, and settings toggles. The database columns remain unchanged.
This commit is contained in:
1 parent
14b0199625
commit
e380c0a31c
32 files changed
+948
-1075
No files matched your search
@@ -10,10 +10,9 @@ interface AIPolishButtonProps {
|
|||||||
onPolished: (newText: string) => void;
|
onPolished: (newText: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
explicit?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AIPolishButton({ text, type, onPolished, className, disabled, explicit = false }: AIPolishButtonProps) {
|
export function AIPolishButton({ text, type, onPolished, className, disabled }: AIPolishButtonProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handlePolish = async (e: React.MouseEvent) => {
|
const handlePolish = async (e: React.MouseEvent) => {
|
||||||
@@ -25,7 +24,7 @@ export function AIPolishButton({ text, type, onPolished, className, disabled, ex
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const polished = await polishContent(text, type, explicit);
|
const polished = await polishContent(text, type, false);
|
||||||
onPolished(polished);
|
onPolished(polished);
|
||||||
toast.success('Text polished!');
|
toast.success('Text polished!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -114,8 +114,6 @@ export function AiGeneratorDialog({
|
|||||||
context={context}
|
context={context}
|
||||||
config={generation.config}
|
config={generation.config}
|
||||||
onCountChange={generation.setCount}
|
onCountChange={generation.setCount}
|
||||||
onExplicitChange={generation.setExplicit}
|
|
||||||
onCrudeLangChange={generation.setCrudeLang}
|
|
||||||
onInspirationChange={generation.setInspiration}
|
onInspirationChange={generation.setInspiration}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import type { GeneratorType, GeneratorContext, GenerationConfig } from './hooks/useAiGeneration';
|
import type { GeneratorType, GeneratorContext, GenerationConfig } from './hooks/useAiGeneration';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -13,8 +12,6 @@ interface GenerationFormProps {
|
|||||||
context?: GeneratorContext;
|
context?: GeneratorContext;
|
||||||
config: GenerationConfig;
|
config: GenerationConfig;
|
||||||
onCountChange: (count: number) => void;
|
onCountChange: (count: number) => void;
|
||||||
onExplicitChange: (isExplicit: boolean) => void;
|
|
||||||
onCrudeLangChange: (crudeLang: boolean) => void;
|
|
||||||
onInspirationChange: (inspiration: string) => void;
|
onInspirationChange: (inspiration: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +24,6 @@ export function GenerationForm({
|
|||||||
context,
|
context,
|
||||||
config,
|
config,
|
||||||
onCountChange,
|
onCountChange,
|
||||||
onExplicitChange,
|
|
||||||
onCrudeLangChange,
|
|
||||||
onInspirationChange,
|
onInspirationChange,
|
||||||
}: GenerationFormProps) {
|
}: GenerationFormProps) {
|
||||||
return (
|
return (
|
||||||
@@ -49,29 +44,6 @@ export function GenerationForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Explicit Toggle */}
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
id="explicit-mode"
|
|
||||||
checked={config.isExplicit}
|
|
||||||
onCheckedChange={onExplicitChange}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="explicit-mode">Explicit Content</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Crude Language Override Toggle */}
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
id="crude-lang"
|
|
||||||
checked={config.crudeLang}
|
|
||||||
onCheckedChange={onCrudeLangChange}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="crude-lang">Crude Language Override</Label>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
(use "fuck", "suck cock" etc. instead of tasteful phrasing)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Inspiration/Suggestions Textarea */}
|
{/* Inspiration/Suggestions Textarea */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="inspiration">Inspiration / Suggestions (optional)</Label>
|
<Label htmlFor="inspiration">Inspiration / Suggestions (optional)</Label>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
generatePack,
|
generatePack,
|
||||||
generateQuestionsWithCouncil,
|
generateQuestionsWithCouncil,
|
||||||
@@ -32,13 +32,10 @@ export interface GeneratorContext {
|
|||||||
existingCategories?: string[];
|
existingCategories?: string[];
|
||||||
existingPacks?: string[];
|
existingPacks?: string[];
|
||||||
existingQuestions?: string[];
|
existingQuestions?: string[];
|
||||||
isExplicit?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerationConfig {
|
export interface GenerationConfig {
|
||||||
count: number;
|
count: number;
|
||||||
isExplicit: boolean;
|
|
||||||
crudeLang: boolean;
|
|
||||||
inspiration: string;
|
inspiration: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +73,6 @@ export function useAiGeneration(
|
|||||||
// Config state
|
// Config state
|
||||||
const [config, setConfig] = useState<GenerationConfig>({
|
const [config, setConfig] = useState<GenerationConfig>({
|
||||||
count: 10,
|
count: 10,
|
||||||
isExplicit: Boolean(context?.isExplicit),
|
|
||||||
crudeLang: false,
|
|
||||||
inspiration: '',
|
inspiration: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,27 +112,11 @@ export function useAiGeneration(
|
|||||||
// Council config
|
// Council config
|
||||||
const councilConfig = getCouncilConfig();
|
const councilConfig = getCouncilConfig();
|
||||||
|
|
||||||
// Update explicit flag when context changes
|
|
||||||
useEffect(() => {
|
|
||||||
setConfig(prev => ({
|
|
||||||
...prev,
|
|
||||||
isExplicit: Boolean(context?.isExplicit),
|
|
||||||
}));
|
|
||||||
}, [context?.isExplicit, type]);
|
|
||||||
|
|
||||||
// Config setters
|
// Config setters
|
||||||
const setCount = useCallback((count: number) => {
|
const setCount = useCallback((count: number) => {
|
||||||
setConfig(prev => ({ ...prev, count }));
|
setConfig(prev => ({ ...prev, count }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setExplicit = useCallback((isExplicit: boolean) => {
|
|
||||||
setConfig(prev => ({ ...prev, isExplicit }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setCrudeLang = useCallback((crudeLang: boolean) => {
|
|
||||||
setConfig(prev => ({ ...prev, crudeLang }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setInspiration = useCallback((inspiration: string) => {
|
const setInspiration = useCallback((inspiration: string) => {
|
||||||
setConfig(prev => ({ ...prev, inspiration }));
|
setConfig(prev => ({ ...prev, inspiration }));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -182,7 +161,7 @@ export function useAiGeneration(
|
|||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setResults({ pack: null, questions: [], categoryIdeas: [], packIdeas: [] });
|
setResults({ pack: null, questions: [], categoryIdeas: [], packIdeas: [] });
|
||||||
setSelectedIndices(new Set());
|
setSelectedIndices(new Set());
|
||||||
setConfig(prev => ({ ...prev, crudeLang: false, inspiration: '' }));
|
setConfig(prev => ({ ...prev, inspiration: '' }));
|
||||||
setReviewState({
|
setReviewState({
|
||||||
reviews: [],
|
reviews: [],
|
||||||
summary: null,
|
summary: null,
|
||||||
@@ -216,7 +195,8 @@ export function useAiGeneration(
|
|||||||
setFilter('all');
|
setFilter('all');
|
||||||
setSourceModel(null);
|
setSourceModel(null);
|
||||||
|
|
||||||
const isExplicit = config.isExplicit;
|
const isExplicit = false;
|
||||||
|
const crudeLang = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (type === 'pack') {
|
if (type === 'pack') {
|
||||||
@@ -232,7 +212,7 @@ export function useAiGeneration(
|
|||||||
const result = await generatePack(
|
const result = await generatePack(
|
||||||
context?.categoryName,
|
context?.categoryName,
|
||||||
isExplicit,
|
isExplicit,
|
||||||
config.crudeLang,
|
crudeLang,
|
||||||
config.inspiration || undefined,
|
config.inspiration || undefined,
|
||||||
avoidNames
|
avoidNames
|
||||||
);
|
);
|
||||||
@@ -249,11 +229,10 @@ export function useAiGeneration(
|
|||||||
const result = await generateQuestionsWithCouncil(
|
const result = await generateQuestionsWithCouncil(
|
||||||
context.packName,
|
context.packName,
|
||||||
config.count,
|
config.count,
|
||||||
undefined,
|
2,
|
||||||
isExplicit ? 5 : 2,
|
|
||||||
context.packDescription || undefined,
|
context.packDescription || undefined,
|
||||||
context.existingQuestions,
|
context.existingQuestions,
|
||||||
config.crudeLang,
|
crudeLang,
|
||||||
config.inspiration || undefined,
|
config.inspiration || undefined,
|
||||||
setProgress
|
setProgress
|
||||||
);
|
);
|
||||||
@@ -309,7 +288,7 @@ export function useAiGeneration(
|
|||||||
const result = await suggestCategories(
|
const result = await suggestCategories(
|
||||||
context?.existingCategories || [],
|
context?.existingCategories || [],
|
||||||
isExplicit,
|
isExplicit,
|
||||||
config.crudeLang,
|
crudeLang,
|
||||||
config.inspiration || undefined,
|
config.inspiration || undefined,
|
||||||
avoidNames
|
avoidNames
|
||||||
);
|
);
|
||||||
@@ -335,7 +314,7 @@ export function useAiGeneration(
|
|||||||
context.categoryName,
|
context.categoryName,
|
||||||
context?.existingPacks || [],
|
context?.existingPacks || [],
|
||||||
isExplicit,
|
isExplicit,
|
||||||
config.crudeLang,
|
crudeLang,
|
||||||
config.inspiration || undefined,
|
config.inspiration || undefined,
|
||||||
avoidNames
|
avoidNames
|
||||||
);
|
);
|
||||||
@@ -366,8 +345,6 @@ export function useAiGeneration(
|
|||||||
// Config
|
// Config
|
||||||
config,
|
config,
|
||||||
setCount,
|
setCount,
|
||||||
setExplicit,
|
|
||||||
setCrudeLang,
|
|
||||||
setInspiration,
|
setInspiration,
|
||||||
|
|
||||||
// Results
|
// Results
|
||||||
|
|||||||
@@ -260,10 +260,10 @@ export function PackFormDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Explicit Toggle */}
|
{/* NSFW Toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label>Explicit Content</Label>
|
<Label>NSFW</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Contains adult or mature content
|
Contains adult or mature content
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Loader2, Sparkles, ArrowRight, Check, Wand2, ShieldCheck, Gauge, Trash2, Package } from 'lucide-react';
|
import { Loader2, Sparkles, ArrowRight, Check, Wand2, ShieldCheck, Trash2, Package } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
analyzeQuestionText,
|
analyzeQuestionText,
|
||||||
analyzeQuestionTargets,
|
analyzeQuestionTargets,
|
||||||
@@ -51,7 +51,6 @@ interface ReviewQuestionsDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
questions: Question[];
|
questions: Question[];
|
||||||
isExplicit?: boolean;
|
|
||||||
onUpdated: () => void;
|
onUpdated: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,14 +63,13 @@ interface CombinedSuggestion {
|
|||||||
deleteSuggestion?: DeletionAnalysis;
|
deleteSuggestion?: DeletionAnalysis;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplicit = false, onUpdated }: ReviewQuestionsDialogProps) {
|
export function ReviewQuestionsDialog({ open, onOpenChange, questions, onUpdated }: ReviewQuestionsDialogProps) {
|
||||||
const [analyzing, setAnalyzing] = useState(false);
|
const [analyzing, setAnalyzing] = useState(false);
|
||||||
const [suggestions, setSuggestions] = useState<CombinedSuggestion[]>([]);
|
const [suggestions, setSuggestions] = useState<CombinedSuggestion[]>([]);
|
||||||
const [existingProps, setExistingProps] = useState<string[]>([]);
|
const [existingProps, setExistingProps] = useState<string[]>([]);
|
||||||
const [selectedTextIds, setSelectedTextIds] = useState<Set<string>>(new Set());
|
const [selectedTextIds, setSelectedTextIds] = useState<Set<string>>(new Set());
|
||||||
const [selectedTargetIds, setSelectedTargetIds] = useState<Set<string>>(new Set());
|
const [selectedTargetIds, setSelectedTargetIds] = useState<Set<string>>(new Set());
|
||||||
const [selectedPropsIds, setSelectedPropsIds] = useState<Set<string>>(new Set());
|
const [selectedPropsIds, setSelectedPropsIds] = useState<Set<string>>(new Set());
|
||||||
const [selectedIntensityIds, setSelectedIntensityIds] = useState<Set<string>>(new Set());
|
|
||||||
const [selectedDeleteIds, setSelectedDeleteIds] = useState<Set<string>>(new Set());
|
const [selectedDeleteIds, setSelectedDeleteIds] = useState<Set<string>>(new Set());
|
||||||
const [step, setStep] = useState<'initial' | 'review'>('initial');
|
const [step, setStep] = useState<'initial' | 'review'>('initial');
|
||||||
const [applying, setApplying] = useState(false);
|
const [applying, setApplying] = useState(false);
|
||||||
@@ -134,10 +132,10 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
|||||||
const [textResults, targetResults, deleteResults, propsResults] = await Promise.all([
|
const [textResults, targetResults, deleteResults, propsResults] = await Promise.all([
|
||||||
analyzeQuestionText(
|
analyzeQuestionText(
|
||||||
questions.map(q => ({ ...q, intensity: q.intensity })),
|
questions.map(q => ({ ...q, intensity: q.intensity })),
|
||||||
isExplicit
|
false
|
||||||
),
|
),
|
||||||
analyzeQuestionTargets(questions),
|
analyzeQuestionTargets(questions),
|
||||||
analyzeQuestionDeletions(questions, isExplicit),
|
analyzeQuestionDeletions(questions, false),
|
||||||
analyzeQuestionProps(
|
analyzeQuestionProps(
|
||||||
questions.map(q => ({
|
questions.map(q => ({
|
||||||
id: q.id,
|
id: q.id,
|
||||||
@@ -192,10 +190,6 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
|||||||
setSelectedTargetIds(new Set(meaningfulTargetResults.map(r => r.id)));
|
setSelectedTargetIds(new Set(meaningfulTargetResults.map(r => r.id)));
|
||||||
setSelectedPropsIds(new Set(meaningfulPropsResults.map(r => r.id)));
|
setSelectedPropsIds(new Set(meaningfulPropsResults.map(r => r.id)));
|
||||||
setSelectedDeleteIds(new Set(deleteResults.map(r => r.id)));
|
setSelectedDeleteIds(new Set(deleteResults.map(r => r.id)));
|
||||||
// Select intensity suggestions by default
|
|
||||||
setSelectedIntensityIds(new Set(
|
|
||||||
textResults.filter(r => r.suggested_intensity != null).map(r => r.id)
|
|
||||||
));
|
|
||||||
setStep('review');
|
setStep('review');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -211,17 +205,15 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
|||||||
const updates: { id: string; data: Record<string, unknown> }[] = [];
|
const updates: { id: string; data: Record<string, unknown> }[] = [];
|
||||||
const deleteIds = Array.from(selectedDeleteIds);
|
const deleteIds = Array.from(selectedDeleteIds);
|
||||||
|
|
||||||
// Build update objects combining text, target, and intensity changes
|
// Build update objects combining text, target, and props changes
|
||||||
suggestions.forEach(suggestion => {
|
suggestions.forEach(suggestion => {
|
||||||
if (selectedDeleteIds.has(suggestion.questionId)) return;
|
if (selectedDeleteIds.has(suggestion.questionId)) return;
|
||||||
|
|
||||||
const textSelected = selectedTextIds.has(suggestion.questionId) && suggestion.textSuggestion;
|
const textSelected = selectedTextIds.has(suggestion.questionId) && suggestion.textSuggestion;
|
||||||
const targetSelected = selectedTargetIds.has(suggestion.questionId) && suggestion.targetSuggestion;
|
const targetSelected = selectedTargetIds.has(suggestion.questionId) && suggestion.targetSuggestion;
|
||||||
const propsSelected = selectedPropsIds.has(suggestion.questionId) && suggestion.propsSuggestion;
|
const propsSelected = selectedPropsIds.has(suggestion.questionId) && suggestion.propsSuggestion;
|
||||||
const intensitySelected = selectedIntensityIds.has(suggestion.questionId) &&
|
|
||||||
suggestion.textSuggestion?.suggested_intensity != null;
|
|
||||||
|
|
||||||
if (textSelected || targetSelected || propsSelected || intensitySelected) {
|
if (textSelected || targetSelected || propsSelected) {
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (textSelected && suggestion.textSuggestion) {
|
if (textSelected && suggestion.textSuggestion) {
|
||||||
@@ -247,10 +239,6 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intensitySelected && suggestion.textSuggestion?.suggested_intensity != null) {
|
|
||||||
updateData.intensity = suggestion.textSuggestion.suggested_intensity;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updateData).length > 0) {
|
if (Object.keys(updateData).length > 0) {
|
||||||
updates.push({ id: suggestion.questionId, data: updateData });
|
updates.push({ id: suggestion.questionId, data: updateData });
|
||||||
}
|
}
|
||||||
@@ -301,7 +289,6 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
|||||||
setSelectedTextIds(new Set());
|
setSelectedTextIds(new Set());
|
||||||
setSelectedTargetIds(new Set());
|
setSelectedTargetIds(new Set());
|
||||||
setSelectedPropsIds(new Set());
|
setSelectedPropsIds(new Set());
|
||||||
setSelectedIntensityIds(new Set());
|
|
||||||
setSelectedDeleteIds(new Set());
|
setSelectedDeleteIds(new Set());
|
||||||
}, 300);
|
}, 300);
|
||||||
};
|
};
|
||||||
@@ -327,13 +314,6 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
|||||||
setSelectedPropsIds(next);
|
setSelectedPropsIds(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleIntensitySelection = (id: string) => {
|
|
||||||
const next = new Set(selectedIntensityIds);
|
|
||||||
if (next.has(id)) next.delete(id);
|
|
||||||
else next.add(id);
|
|
||||||
setSelectedIntensityIds(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDeleteSelection = (id: string) => {
|
const toggleDeleteSelection = (id: string) => {
|
||||||
const next = new Set(selectedDeleteIds);
|
const next = new Set(selectedDeleteIds);
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) next.delete(id);
|
||||||
@@ -387,14 +367,12 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
|||||||
const textSuggestionCount = suggestions.filter(s => s.textSuggestion).length;
|
const textSuggestionCount = suggestions.filter(s => s.textSuggestion).length;
|
||||||
const targetSuggestionCount = suggestions.filter(s => s.targetSuggestion).length;
|
const targetSuggestionCount = suggestions.filter(s => s.targetSuggestion).length;
|
||||||
const propsSuggestionCount = suggestions.filter(s => s.propsSuggestion).length;
|
const propsSuggestionCount = suggestions.filter(s => s.propsSuggestion).length;
|
||||||
const intensitySuggestionCount = suggestions.filter(s => s.textSuggestion?.suggested_intensity != null).length;
|
|
||||||
const deleteSuggestionCount = suggestions.filter(s => s.deleteSuggestion).length;
|
const deleteSuggestionCount = suggestions.filter(s => s.deleteSuggestion).length;
|
||||||
const effectiveTextCount = Array.from(selectedTextIds).filter(id => !selectedDeleteIds.has(id)).length;
|
const effectiveTextCount = Array.from(selectedTextIds).filter(id => !selectedDeleteIds.has(id)).length;
|
||||||
const effectiveTargetCount = Array.from(selectedTargetIds).filter(id => !selectedDeleteIds.has(id)).length;
|
const effectiveTargetCount = Array.from(selectedTargetIds).filter(id => !selectedDeleteIds.has(id)).length;
|
||||||
const effectivePropsCount = Array.from(selectedPropsIds).filter(id => !selectedDeleteIds.has(id)).length;
|
const effectivePropsCount = Array.from(selectedPropsIds).filter(id => !selectedDeleteIds.has(id)).length;
|
||||||
const effectiveIntensityCount = Array.from(selectedIntensityIds).filter(id => !selectedDeleteIds.has(id)).length;
|
const totalSelectedCount = effectiveTextCount + effectiveTargetCount + effectivePropsCount + selectedDeleteIds.size;
|
||||||
const totalSelectedCount = effectiveTextCount + effectiveTargetCount + effectivePropsCount + effectiveIntensityCount + selectedDeleteIds.size;
|
const hasSuggestions = textSuggestionCount > 0 || targetSuggestionCount > 0 || propsSuggestionCount > 0 || deleteSuggestionCount > 0;
|
||||||
const hasSuggestions = textSuggestionCount > 0 || targetSuggestionCount > 0 || propsSuggestionCount > 0 || intensitySuggestionCount > 0 || deleteSuggestionCount > 0;
|
|
||||||
|
|
||||||
// Select all/none helpers
|
// Select all/none helpers
|
||||||
const selectAllText = () => setSelectedTextIds(new Set(suggestions.filter(s => s.textSuggestion).map(s => s.questionId)));
|
const selectAllText = () => setSelectedTextIds(new Set(suggestions.filter(s => s.textSuggestion).map(s => s.questionId)));
|
||||||
@@ -403,24 +381,18 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
|||||||
const selectNoneTargets = () => setSelectedTargetIds(new Set());
|
const selectNoneTargets = () => setSelectedTargetIds(new Set());
|
||||||
const selectAllProps = () => setSelectedPropsIds(new Set(suggestions.filter(s => s.propsSuggestion).map(s => s.questionId)));
|
const selectAllProps = () => setSelectedPropsIds(new Set(suggestions.filter(s => s.propsSuggestion).map(s => s.questionId)));
|
||||||
const selectNoneProps = () => setSelectedPropsIds(new Set());
|
const selectNoneProps = () => setSelectedPropsIds(new Set());
|
||||||
const selectAllIntensity = () => setSelectedIntensityIds(new Set(
|
|
||||||
suggestions.filter(s => s.textSuggestion?.suggested_intensity != null).map(s => s.questionId)
|
|
||||||
));
|
|
||||||
const selectNoneIntensity = () => setSelectedIntensityIds(new Set());
|
|
||||||
const selectAllDeletes = () => setSelectedDeleteIds(new Set(suggestions.filter(s => s.deleteSuggestion).map(s => s.questionId)));
|
const selectAllDeletes = () => setSelectedDeleteIds(new Set(suggestions.filter(s => s.deleteSuggestion).map(s => s.questionId)));
|
||||||
const selectNoneDeletes = () => setSelectedDeleteIds(new Set());
|
const selectNoneDeletes = () => setSelectedDeleteIds(new Set());
|
||||||
const selectAll = () => {
|
const selectAll = () => {
|
||||||
selectAllText();
|
selectAllText();
|
||||||
selectAllTargets();
|
selectAllTargets();
|
||||||
selectAllProps();
|
selectAllProps();
|
||||||
selectAllIntensity();
|
|
||||||
selectAllDeletes();
|
selectAllDeletes();
|
||||||
};
|
};
|
||||||
const selectNone = () => {
|
const selectNone = () => {
|
||||||
selectNoneText();
|
selectNoneText();
|
||||||
selectNoneTargets();
|
selectNoneTargets();
|
||||||
selectNoneProps();
|
selectNoneProps();
|
||||||
selectNoneIntensity();
|
|
||||||
selectNoneDeletes();
|
selectNoneDeletes();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -462,10 +434,6 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
|||||||
<Package className="h-5 w-5 text-cyan-400" />
|
<Package className="h-5 w-5 text-cyan-400" />
|
||||||
<span className="text-sm font-medium text-cyan-300">Required Props</span>
|
<span className="text-sm font-medium text-cyan-300">Required Props</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 bg-amber-500/20 px-4 py-2 rounded-lg">
|
|
||||||
<Gauge className="h-5 w-5 text-amber-400" />
|
|
||||||
<span className="text-sm font-medium text-amber-300">Intensity Level</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 bg-rose-500/20 px-4 py-2 rounded-lg">
|
<div className="flex items-center gap-2 bg-rose-500/20 px-4 py-2 rounded-lg">
|
||||||
<Trash2 className="h-5 w-5 text-rose-400" />
|
<Trash2 className="h-5 w-5 text-rose-400" />
|
||||||
<span className="text-sm font-medium text-rose-300">Deletion Suggestions</span>
|
<span className="text-sm font-medium text-rose-300">Deletion Suggestions</span>
|
||||||
@@ -506,10 +474,6 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
|||||||
<Package className="h-4 w-4 text-cyan-500" />
|
<Package className="h-4 w-4 text-cyan-500" />
|
||||||
{propsSuggestionCount} props fixes
|
{propsSuggestionCount} props fixes
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Gauge className="h-4 w-4 text-amber-500" />
|
|
||||||
{intensitySuggestionCount} intensity fixes
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Trash2 className="h-4 w-4 text-rose-500" />
|
<Trash2 className="h-4 w-4 text-rose-500" />
|
||||||
{deleteSuggestionCount} delete suggestions
|
{deleteSuggestionCount} delete suggestions
|
||||||
@@ -572,22 +536,6 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[10%]">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Gauge className="h-4 w-4 text-amber-500" />
|
|
||||||
Intensity
|
|
||||||
{intensitySuggestionCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-xs"
|
|
||||||
onClick={() => selectedIntensityIds.size === intensitySuggestionCount ? selectNoneIntensity() : selectAllIntensity()}
|
|
||||||
>
|
|
||||||
{selectedIntensityIds.size === intensitySuggestionCount ? 'None' : 'All'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="w-[10%]">
|
<TableHead className="w-[10%]">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Trash2 className="h-4 w-4 text-rose-500" />
|
<Trash2 className="h-4 w-4 text-rose-500" />
|
||||||
@@ -757,50 +705,6 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Intensity Column */}
|
|
||||||
<TableCell className="align-top py-3">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
Current: {suggestion.question.intensity}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{suggestion.textSuggestion?.suggested_intensity != null ? (
|
|
||||||
<div
|
|
||||||
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedIntensityIds.has(suggestion.questionId)
|
|
||||||
? 'bg-amber-500/20 border-amber-500/50'
|
|
||||||
: 'bg-muted/30 border-transparent hover:bg-muted/50'
|
|
||||||
}`}
|
|
||||||
onClick={() => toggleIntensitySelection(suggestion.questionId)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedIntensityIds.has(suggestion.questionId)}
|
|
||||||
onCheckedChange={() => toggleIntensitySelection(suggestion.questionId)}
|
|
||||||
className="mt-0.5"
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
|
||||||
<Badge className="text-xs bg-amber-500/30 text-amber-300 hover:bg-amber-500/30">
|
|
||||||
{suggestion.textSuggestion.suggested_intensity}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{suggestion.textSuggestion.intensity_reason && (
|
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
|
||||||
{suggestion.textSuggestion.intensity_reason}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
<Check className="h-4 w-4 text-green-500 inline mr-1" />
|
|
||||||
Correct
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Delete Column */}
|
{/* Delete Column */}
|
||||||
<TableCell className="align-top py-3">
|
<TableCell className="align-top py-3">
|
||||||
{suggestion.deleteSuggestion ? (
|
{suggestion.deleteSuggestion ? (
|
||||||
|
|||||||
@@ -146,26 +146,26 @@ export function Breadcrumbs() {
|
|||||||
if (breadcrumbs.length === 0 && !loading) return null;
|
if (breadcrumbs.length === 0 && !loading) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex items-center space-x-1 text-sm">
|
<nav className="flex items-center gap-1 text-sm overflow-x-auto whitespace-nowrap max-w-full pr-2">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="flex items-center text-muted-foreground hover:text-primary transition-colors p-1 rounded-md hover:bg-white/5"
|
className="flex items-center text-muted-foreground hover:text-primary transition-colors p-1 rounded-md hover:bg-white/5 flex-shrink-0"
|
||||||
>
|
>
|
||||||
<Home className="h-4 w-4" />
|
<Home className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{breadcrumbs.map((item, index) => (
|
{breadcrumbs.map((item, index) => (
|
||||||
<div key={index} className="flex items-center">
|
<div key={index} className="flex items-center flex-shrink-0">
|
||||||
<ChevronRight className="h-4 w-4 mx-1 text-muted-foreground/50" />
|
<ChevronRight className="h-4 w-4 mx-1 text-muted-foreground/50" />
|
||||||
{item.href ? (
|
{item.href ? (
|
||||||
<Link
|
<Link
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className="text-muted-foreground hover:text-primary transition-colors px-2 py-1 rounded-md hover:bg-white/5"
|
className="text-muted-foreground hover:text-primary transition-colors px-2 py-1 rounded-md hover:bg-white/5 max-w-[160px] truncate"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-foreground font-medium px-2 py-1">
|
<span className="text-foreground font-medium px-2 py-1 max-w-[180px] truncate">
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const TabsList = React.forwardRef<
|
|||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
"flex w-full flex-wrap items-center justify-start gap-1 rounded-md bg-muted p-1 text-muted-foreground sm:inline-flex sm:w-auto sm:flex-nowrap sm:justify-center",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { TextAnalysis } from '../types';
|
|||||||
* Analyze question text and suggest improvements
|
* Analyze question text and suggest improvements
|
||||||
*/
|
*/
|
||||||
export async function analyzeQuestionText(
|
export async function analyzeQuestionText(
|
||||||
questions: { id: string; text: string; partner_text?: string | null; intensity?: number }[],
|
questions: { id: string; text: string; partner_text?: string | null }[],
|
||||||
isExplicit: boolean = false
|
isExplicit: boolean = false
|
||||||
): Promise<TextAnalysis[]> {
|
): Promise<TextAnalysis[]> {
|
||||||
const openai = getOpenAI();
|
const openai = getOpenAI();
|
||||||
@@ -19,7 +19,6 @@ export async function analyzeQuestionText(
|
|||||||
id: q.id,
|
id: q.id,
|
||||||
text: q.text,
|
text: q.text,
|
||||||
partner_text: q.partner_text,
|
partner_text: q.partner_text,
|
||||||
intensity: q.intensity,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const toneInstruction = isExplicit
|
const toneInstruction = isExplicit
|
||||||
@@ -83,17 +82,6 @@ Cards should be "Proposals" for specific actions, NOT interview questions.
|
|||||||
8. Skip questions that are ALREADY well-phrased
|
8. Skip questions that are ALREADY well-phrased
|
||||||
</rules>
|
</rules>
|
||||||
|
|
||||||
<intensity_guide>
|
|
||||||
Check if current intensity matches the activity:
|
|
||||||
1 (Gentle): Emotional bonding, non-sexual (cooking, cuddling, foot massage)
|
|
||||||
2 (Warm): Romantic, affectionate touch (slow dance, sensual massage, kissing)
|
|
||||||
3 (Playful): Light sexual exploration (oral, mutual masturbation, light roleplay)
|
|
||||||
4 (Steamy): Explicit sex, moderate adventure (intercourse, light bondage, anal play)
|
|
||||||
5 (Intense): Advanced/BDSM/extreme (impact play, power dynamics, taboo kinks)
|
|
||||||
|
|
||||||
If intensity seems wrong, include suggested_intensity and intensity_reason.
|
|
||||||
</intensity_guide>
|
|
||||||
|
|
||||||
<questions_to_analyze>
|
<questions_to_analyze>
|
||||||
${JSON.stringify(simplifiedQuestions)}
|
${JSON.stringify(simplifiedQuestions)}
|
||||||
</questions_to_analyze>
|
</questions_to_analyze>
|
||||||
@@ -105,14 +93,12 @@ ${JSON.stringify(simplifiedQuestions)}
|
|||||||
"id": string, // Question ID
|
"id": string, // Question ID
|
||||||
"suggested_text": string, // Improved text (short, direct)
|
"suggested_text": string, // Improved text (short, direct)
|
||||||
"suggested_partner_text": string|null, // Improved partner_text or null if symmetric
|
"suggested_partner_text": string|null, // Improved partner_text or null if symmetric
|
||||||
"reason": string, // Brief explanation of text improvement
|
"reason": string // Brief explanation of text improvement
|
||||||
"suggested_intensity": number|null, // 1-5 if intensity seems wrong, null if correct
|
|
||||||
"intensity_reason": string|null // Why intensity should change (if applicable)
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
Only include questions that need text improvement OR intensity adjustment.
|
Only include questions that need text improvement.
|
||||||
</output_format>`;
|
</output_format>`;
|
||||||
|
|
||||||
const systemMessage = isExplicit
|
const systemMessage = isExplicit
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// AI Configuration Constants
|
// AI Configuration Constants
|
||||||
// Council config, tone levels, intensity levels, and prompt guides
|
// Council config, tone levels, and prompt guides
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
import { getCachedAiConfig, preloadAiConfig, type AiConfig } from '@/hooks/useAiConfig';
|
import { getCachedAiConfig, preloadAiConfig, type AiConfig } from '@/hooks/useAiConfig';
|
||||||
@@ -33,7 +33,6 @@ export function getCouncilConfig(): CouncilConfig {
|
|||||||
reviewerModel: remoteConfig.council_reviewer_model || 'google/gemini-pro-1.5',
|
reviewerModel: remoteConfig.council_reviewer_model || 'google/gemini-pro-1.5',
|
||||||
reviewerTemperature: remoteConfig.council_reviewer_temperature ?? 0.3,
|
reviewerTemperature: remoteConfig.council_reviewer_temperature ?? 0.3,
|
||||||
selectionMode: remoteConfig.council_selection_mode || 'whole_set',
|
selectionMode: remoteConfig.council_selection_mode || 'whole_set',
|
||||||
cherryPickEnsureIntensityDistribution: remoteConfig.cherry_pick_ensure_intensity_distribution ?? true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +43,6 @@ export function getCouncilConfig(): CouncilConfig {
|
|||||||
reviewerModel: import.meta.env.VITE_COUNCIL_REVIEWER_MODEL || 'google/gemini-pro-1.5',
|
reviewerModel: import.meta.env.VITE_COUNCIL_REVIEWER_MODEL || 'google/gemini-pro-1.5',
|
||||||
reviewerTemperature: 0.3,
|
reviewerTemperature: 0.3,
|
||||||
selectionMode: 'whole_set',
|
selectionMode: 'whole_set',
|
||||||
cherryPickEnsureIntensityDistribution: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,67 +67,6 @@ export const TONE_LEVELS = [
|
|||||||
{ level: 5, label: 'Intense', description: 'Advanced/BDSM/Extreme exploration' },
|
{ level: 5, label: 'Intense', description: 'Advanced/BDSM/Extreme exploration' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// INTENSITY LEVELS
|
|
||||||
// For UI display - consistent with AI grading
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export const INTENSITY_LEVELS = [
|
|
||||||
{ level: 1, label: 'Gentle', description: 'Pure emotional connection & non-sexual bonding', color: 'bg-green-500' },
|
|
||||||
{ level: 2, label: 'Warm', description: 'Romantic atmosphere & affectionate touch', color: 'bg-lime-500' },
|
|
||||||
{ level: 3, label: 'Playful', description: 'Light sexual exploration & sensual discovery', color: 'bg-yellow-500' },
|
|
||||||
{ level: 4, label: 'Steamy', description: 'Explicit sexual activities & moderate adventure', color: 'bg-orange-500' },
|
|
||||||
{ level: 5, label: 'Intense', description: 'Advanced/BDSM/Extreme exploration', color: 'bg-red-500' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// INTENSITY GRADING GUIDE
|
|
||||||
// Full version for detailed AI prompts
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export const INTENSITY_GUIDE = `
|
|
||||||
INTENSITY GRADING GUIDE - Use this to assign intensity levels consistently:
|
|
||||||
|
|
||||||
Level 1 - GENTLE (Pure emotional connection & non-sexual bonding):
|
|
||||||
- Focus: Emotional safety, friendship-based activities, quality time
|
|
||||||
- Examples: Cook together, take a walk holding hands, give non-sexual foot massages, watch a movie cuddling
|
|
||||||
- Emotional bonding without sexual undertones
|
|
||||||
|
|
||||||
Level 2 - WARM (Romantic atmosphere & affectionate touch):
|
|
||||||
- Focus: Creating romantic moments, sensual but non-sexual touch, building anticipation
|
|
||||||
- Examples: Slow dance in the living room, candlelit bath together (non-sexual), extended kissing sessions, sensual massage with oils
|
|
||||||
- Romantic intimacy building up to arousal
|
|
||||||
|
|
||||||
Level 3 - PLAYFUL (Light sexual exploration & sensual discovery):
|
|
||||||
- Focus: Sexual touch without penetration, playful experimentation, building arousal
|
|
||||||
- Examples: Mutual masturbation, oral sex, light roleplay (doctor/patient), using basic toys together
|
|
||||||
- Fun and exploratory sexual contact
|
|
||||||
|
|
||||||
Level 4 - STEAMY (Explicit sexual activities & moderate adventure):
|
|
||||||
- Focus: Full sexual intercourse with adventurous elements, moderate BDSM/kink
|
|
||||||
- Examples: Sex in different locations/positions, light bondage, anal play with toys, recording consensual intimate moments
|
|
||||||
- Passionate and adventurous sexual connection
|
|
||||||
|
|
||||||
Level 5 - INTENSE (Advanced/BDSM/Extreme exploration):
|
|
||||||
- Focus: Edge play, power dynamics, intense physical experiences
|
|
||||||
- Examples: Heavy impact play, advanced bondage, consensual non-consent scenes, multi-partner activities
|
|
||||||
- Very intense, taboo kinks, and extreme exploration
|
|
||||||
`;
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// INTENSITY GUIDE (SHORT)
|
|
||||||
// Condensed version for prompts to save tokens
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export const INTENSITY_GUIDE_SHORT = `
|
|
||||||
INTENSITY LEVELS:
|
|
||||||
1 (Gentle): Pure emotional connection, non-sexual bonding (cooking, cuddling, foot massages)
|
|
||||||
2 (Warm): Romantic atmosphere, affectionate touch (slow dance, sensual massage, kissing)
|
|
||||||
3 (Playful): Light sexual exploration, sensual discovery (oral, mutual masturbation, light roleplay)
|
|
||||||
4 (Steamy): Explicit sexual activities, moderate adventure (sex positions, light bondage, anal play)
|
|
||||||
5 (Intense): Advanced/BDSM/Extreme exploration (impact play, power dynamics, taboo kinks)
|
|
||||||
`;
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// REVIEW GUIDELINES
|
// REVIEW GUIDELINES
|
||||||
// Used by council reviewer for quality assessment
|
// Used by council reviewer for quality assessment
|
||||||
@@ -155,32 +92,25 @@ REVIEW CRITERIA - Score each 1-10:
|
|||||||
- Partner text (if present) clearly describes receiver's experience
|
- Partner text (if present) clearly describes receiver's experience
|
||||||
- No confusing or ambiguous phrasing
|
- No confusing or ambiguous phrasing
|
||||||
|
|
||||||
4. INTENSITY ACCURACY:
|
4. ANATOMICAL CONSISTENCY:
|
||||||
Level 1 (Gentle): Pure emotional connection, non-sexual bonding
|
|
||||||
Level 2 (Warm): Romantic atmosphere, affectionate touch
|
|
||||||
Level 3 (Playful): Light sexual exploration, sensual discovery (oral, toys)
|
|
||||||
Level 4 (Steamy): Explicit sexual activities, moderate adventure (intercourse, light bondage)
|
|
||||||
Level 5 (Intense): Advanced/BDSM/Extreme exploration (impact play, power dynamics)
|
|
||||||
|
|
||||||
5. ANATOMICAL CONSISTENCY:
|
|
||||||
- No mixed male/female anatomy in alternatives
|
- No mixed male/female anatomy in alternatives
|
||||||
- BAD: "Finger or give your partner a handjob"
|
- BAD: "Finger or give your partner a handjob"
|
||||||
- GOOD: Pick one activity
|
- GOOD: Pick one activity
|
||||||
|
|
||||||
6. PARTNER TEXT QUALITY (if applicable):
|
5. PARTNER TEXT QUALITY (if applicable):
|
||||||
- Engaging and enticing, not clinical
|
- Engaging and enticing, not clinical
|
||||||
- BAD: "Receive oral from your partner"
|
- BAD: "Receive oral from your partner"
|
||||||
- GOOD: "Let your partner pleasure you with their mouth"
|
- GOOD: "Let your partner pleasure you with their mouth"
|
||||||
- When initiator causes a response, frame as allowing: "Let your partner make you moan"
|
- When initiator causes a response, frame as allowing: "Let your partner make you moan"
|
||||||
|
|
||||||
7. COUPLE TARGETING (flag if incorrect):
|
6. COUPLE TARGETING (flag if incorrect):
|
||||||
- Default: ALL couples (null) unless explicit anatomical requirement
|
- Default: ALL couples (null) unless explicit anatomical requirement
|
||||||
- Restrict to ['male+male', 'female+male'] ONLY if activity requires penis
|
- Restrict to ['male+male', 'female+male'] ONLY if activity requires penis
|
||||||
- Restrict to ['female+male', 'female+female'] ONLY if activity requires vagina
|
- Restrict to ['female+male', 'female+female'] ONLY if activity requires vagina
|
||||||
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone)
|
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone)
|
||||||
- Flag if targeting seems too restrictive or missing needed restrictions
|
- Flag if targeting seems too restrictive or missing needed restrictions
|
||||||
|
|
||||||
8. INITIATOR TARGETING (for asymmetric questions only):
|
7. INITIATOR TARGETING (for asymmetric questions only):
|
||||||
- Default: null (anyone can initiate) unless explicit anatomical requirement
|
- Default: null (anyone can initiate) unless explicit anatomical requirement
|
||||||
- Set initiator based on WHO DOES the action in "text" field
|
- Set initiator based on WHO DOES the action in "text" field
|
||||||
- "Swallow your partner's cum" -> initiator: female (in M+F, receiver of cum)
|
- "Swallow your partner's cum" -> initiator: female (in M+F, receiver of cum)
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import type {
|
|||||||
PooledQuestion,
|
PooledQuestion,
|
||||||
CherryPickEvaluation,
|
CherryPickEvaluation,
|
||||||
CherryPickResult,
|
CherryPickResult,
|
||||||
CouncilConfig,
|
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,7 +33,6 @@ export async function evaluateQuestionPool(
|
|||||||
index: i,
|
index: i,
|
||||||
text: q.text,
|
text: q.text,
|
||||||
partner_text: q.partner_text || null,
|
partner_text: q.partner_text || null,
|
||||||
intensity: q.intensity,
|
|
||||||
generatorIndex: q.sourceGeneratorIndex,
|
generatorIndex: q.sourceGeneratorIndex,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -69,11 +67,10 @@ ${REVIEW_GUIDELINES}
|
|||||||
|
|
||||||
<scoring_weights>
|
<scoring_weights>
|
||||||
Your scores will be weighted as follows for final selection:
|
Your scores will be weighted as follows for final selection:
|
||||||
- Guideline Compliance: 25%
|
- Guideline Compliance: 30%
|
||||||
- Creativity: 20%
|
- Creativity: 25%
|
||||||
- Clarity: 20%
|
- Clarity: 25%
|
||||||
- Uniqueness: 20%
|
- Uniqueness: 20%
|
||||||
- Intensity Accuracy: 15%
|
|
||||||
|
|
||||||
Calibrate your scores knowing these weights. A low compliance score has the most impact.
|
Calibrate your scores knowing these weights. A low compliance score has the most impact.
|
||||||
</scoring_weights>
|
</scoring_weights>
|
||||||
@@ -86,8 +83,7 @@ Two questions are duplicates if they:
|
|||||||
|
|
||||||
For duplicates, mark the WORSE version as duplicate and keep the version with:
|
For duplicates, mark the WORSE version as duplicate and keep the version with:
|
||||||
1. Better phrasing/creativity
|
1. Better phrasing/creativity
|
||||||
2. More appropriate intensity grading
|
2. Better partner_text (if applicable)
|
||||||
3. Better partner_text (if applicable)
|
|
||||||
</duplicate_detection>
|
</duplicate_detection>
|
||||||
|
|
||||||
<verdict_rules>
|
<verdict_rules>
|
||||||
@@ -110,7 +106,6 @@ ${JSON.stringify(questionsForEval, null, 2)}
|
|||||||
"guidelineCompliance": 1-10,
|
"guidelineCompliance": 1-10,
|
||||||
"creativity": 1-10,
|
"creativity": 1-10,
|
||||||
"clarity": 1-10,
|
"clarity": 1-10,
|
||||||
"intensityAccuracy": 1-10,
|
|
||||||
"uniqueness": 1-10
|
"uniqueness": 1-10
|
||||||
},
|
},
|
||||||
"isDuplicate": boolean,
|
"isDuplicate": boolean,
|
||||||
@@ -151,16 +146,12 @@ Evaluate ALL ${pooledQuestions.length} questions.
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select top N questions from evaluations, optionally balancing intensity levels
|
* Select top N questions from evaluations
|
||||||
*/
|
*/
|
||||||
export function selectTopQuestions(
|
export function selectTopQuestions(
|
||||||
pooledQuestions: PooledQuestion[],
|
pooledQuestions: PooledQuestion[],
|
||||||
evaluations: CherryPickEvaluation[],
|
evaluations: CherryPickEvaluation[],
|
||||||
requestedCount: number,
|
requestedCount: number
|
||||||
options: {
|
|
||||||
ensureIntensityDistribution: boolean;
|
|
||||||
requestedIntensity?: number;
|
|
||||||
}
|
|
||||||
): CherryPickResult {
|
): CherryPickResult {
|
||||||
// Step 1: Filter out duplicates (keep only the original, not the duplicate)
|
// Step 1: Filter out duplicates (keep only the original, not the duplicate)
|
||||||
const duplicateIndices = new Set<number>();
|
const duplicateIndices = new Set<number>();
|
||||||
@@ -178,74 +169,25 @@ export function selectTopQuestions(
|
|||||||
question: pooledQuestions[e.questionIndex],
|
question: pooledQuestions[e.questionIndex],
|
||||||
// Weighted overall score
|
// Weighted overall score
|
||||||
weightedScore: (
|
weightedScore: (
|
||||||
(e.scores.guidelineCompliance * 0.25) +
|
(e.scores.guidelineCompliance * 0.30) +
|
||||||
(e.scores.creativity * 0.20) +
|
(e.scores.creativity * 0.25) +
|
||||||
(e.scores.clarity * 0.20) +
|
(e.scores.clarity * 0.25) +
|
||||||
(e.scores.intensityAccuracy * 0.15) +
|
|
||||||
(e.scores.uniqueness * 0.20)
|
(e.scores.uniqueness * 0.20)
|
||||||
) * 10,
|
) * 10,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.weightedScore - a.weightedScore);
|
.sort((a, b) => b.weightedScore - a.weightedScore);
|
||||||
|
|
||||||
let selected: typeof scoredQuestions = [];
|
// Step 3: Select top N by score
|
||||||
|
const selected = scoredQuestions.slice(0, requestedCount);
|
||||||
// Step 3: Select questions
|
|
||||||
if (options.requestedIntensity) {
|
|
||||||
// If specific intensity requested, just take top N of that intensity
|
|
||||||
const matchingIntensity = scoredQuestions.filter(
|
|
||||||
sq => sq.question.intensity === options.requestedIntensity
|
|
||||||
);
|
|
||||||
selected = matchingIntensity.slice(0, requestedCount);
|
|
||||||
} else if (options.ensureIntensityDistribution) {
|
|
||||||
// Balance across intensity levels
|
|
||||||
const perIntensity = Math.ceil(requestedCount / 5);
|
|
||||||
const byIntensity: Record<number, typeof scoredQuestions> = { 1: [], 2: [], 3: [], 4: [], 5: [] };
|
|
||||||
|
|
||||||
for (const sq of scoredQuestions) {
|
|
||||||
const intensity = sq.question.intensity;
|
|
||||||
if (intensity >= 1 && intensity <= 5) {
|
|
||||||
byIntensity[intensity].push(sq);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Take top N from each intensity level
|
|
||||||
for (let intensity = 1; intensity <= 5; intensity++) {
|
|
||||||
const toTake = byIntensity[intensity].slice(0, perIntensity);
|
|
||||||
selected.push(...toTake);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we still need more, fill from remaining top-scored questions
|
|
||||||
if (selected.length < requestedCount) {
|
|
||||||
const selectedIndices = new Set(selected.map(s => s.evaluation.questionIndex));
|
|
||||||
const remaining = scoredQuestions.filter(
|
|
||||||
sq => !selectedIndices.has(sq.evaluation.questionIndex)
|
|
||||||
);
|
|
||||||
selected.push(...remaining.slice(0, requestedCount - selected.length));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort final selection by score
|
|
||||||
selected.sort((a, b) => b.weightedScore - a.weightedScore);
|
|
||||||
selected = selected.slice(0, requestedCount);
|
|
||||||
} else {
|
|
||||||
// Simple top N by score
|
|
||||||
selected = scoredQuestions.slice(0, requestedCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Build result
|
// Step 4: Build result
|
||||||
const selectedQuestions = selected.map(s => s.question);
|
const selectedQuestions = selected.map(s => s.question);
|
||||||
|
|
||||||
// Calculate intensity distribution
|
|
||||||
const intensityDistribution: Record<number, number> = {};
|
|
||||||
for (const q of selectedQuestions) {
|
|
||||||
intensityDistribution[q.intensity] = (intensityDistribution[q.intensity] || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedQuestions,
|
selectedQuestions,
|
||||||
evaluations,
|
evaluations,
|
||||||
poolSize: pooledQuestions.length,
|
poolSize: pooledQuestions.length,
|
||||||
duplicatesRemoved: duplicateIndices.size,
|
duplicatesRemoved: duplicateIndices.size,
|
||||||
intensityDistribution,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,8 +197,7 @@ export function selectTopQuestions(
|
|||||||
export async function performCherryPickSelection(
|
export async function performCherryPickSelection(
|
||||||
candidates: GenerationCandidate[],
|
candidates: GenerationCandidate[],
|
||||||
packContext: { name: string; description?: string | null; isExplicit: boolean; tone: ToneLevel },
|
packContext: { name: string; description?: string | null; isExplicit: boolean; tone: ToneLevel },
|
||||||
requestedCount: number,
|
requestedCount: number
|
||||||
config: CouncilConfig
|
|
||||||
): Promise<{
|
): Promise<{
|
||||||
questions: GeneratedQuestion[];
|
questions: GeneratedQuestion[];
|
||||||
reviews: QuestionReview[];
|
reviews: QuestionReview[];
|
||||||
@@ -280,10 +221,7 @@ export async function performCherryPickSelection(
|
|||||||
const evaluations = await evaluateQuestionPool(pooledQuestions, packContext, requestedCount);
|
const evaluations = await evaluateQuestionPool(pooledQuestions, packContext, requestedCount);
|
||||||
|
|
||||||
// Step 3: Select top questions
|
// Step 3: Select top questions
|
||||||
const cherryPickResult = selectTopQuestions(pooledQuestions, evaluations, requestedCount, {
|
const cherryPickResult = selectTopQuestions(pooledQuestions, evaluations, requestedCount);
|
||||||
ensureIntensityDistribution: config.cherryPickEnsureIntensityDistribution,
|
|
||||||
requestedIntensity: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 4: Convert to standard review format for UI compatibility
|
// Step 4: Convert to standard review format for UI compatibility
|
||||||
const reviews: QuestionReview[] = cherryPickResult.selectedQuestions.map((q, i) => {
|
const reviews: QuestionReview[] = cherryPickResult.selectedQuestions.map((q, i) => {
|
||||||
@@ -302,7 +240,6 @@ export async function performCherryPickSelection(
|
|||||||
guidelineCompliance: 7,
|
guidelineCompliance: 7,
|
||||||
creativity: 7,
|
creativity: 7,
|
||||||
clarity: 7,
|
clarity: 7,
|
||||||
intensityAccuracy: 7,
|
|
||||||
anatomicalConsistency: 10,
|
anatomicalConsistency: 10,
|
||||||
partnerTextQuality: 10,
|
partnerTextQuality: 10,
|
||||||
coupleTargeting: 10,
|
coupleTargeting: 10,
|
||||||
@@ -324,7 +261,6 @@ export async function performCherryPickSelection(
|
|||||||
guidelineCompliance: evaluation.scores.guidelineCompliance,
|
guidelineCompliance: evaluation.scores.guidelineCompliance,
|
||||||
creativity: evaluation.scores.creativity,
|
creativity: evaluation.scores.creativity,
|
||||||
clarity: evaluation.scores.clarity,
|
clarity: evaluation.scores.clarity,
|
||||||
intensityAccuracy: evaluation.scores.intensityAccuracy,
|
|
||||||
// Cherry-pick doesn't evaluate these, default to passing
|
// Cherry-pick doesn't evaluate these, default to passing
|
||||||
anatomicalConsistency: 10,
|
anatomicalConsistency: 10,
|
||||||
partnerTextQuality: 10,
|
partnerTextQuality: 10,
|
||||||
@@ -346,8 +282,7 @@ export async function performCherryPickSelection(
|
|||||||
totalScore += review.scores.guidelineCompliance || 0;
|
totalScore += review.scores.guidelineCompliance || 0;
|
||||||
totalScore += review.scores.creativity || 0;
|
totalScore += review.scores.creativity || 0;
|
||||||
totalScore += review.scores.clarity || 0;
|
totalScore += review.scores.clarity || 0;
|
||||||
totalScore += review.scores.intensityAccuracy || 0;
|
scoreCount += 3;
|
||||||
scoreCount += 4;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const overallQuality = scoreCount > 0 ? Math.round((totalScore / scoreCount) * 10) / 10 : 0;
|
const overallQuality = scoreCount > 0 ? Math.round((totalScore / scoreCount) * 10) / 10 : 0;
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import type {
|
|||||||
export async function generateQuestionsWithCouncil(
|
export async function generateQuestionsWithCouncil(
|
||||||
packName: string,
|
packName: string,
|
||||||
count: number = 10,
|
count: number = 10,
|
||||||
intensity?: number,
|
|
||||||
tone: ToneLevel = 3,
|
tone: ToneLevel = 3,
|
||||||
packDescription?: string,
|
packDescription?: string,
|
||||||
existingQuestions?: string[],
|
existingQuestions?: string[],
|
||||||
@@ -90,7 +89,6 @@ export async function generateQuestionsWithCouncil(
|
|||||||
const questions = await generateQuestions(
|
const questions = await generateQuestions(
|
||||||
packName,
|
packName,
|
||||||
count,
|
count,
|
||||||
intensity,
|
|
||||||
tone,
|
tone,
|
||||||
packDescription,
|
packDescription,
|
||||||
existingQuestions,
|
existingQuestions,
|
||||||
@@ -138,7 +136,6 @@ export async function generateQuestionsWithCouncil(
|
|||||||
gen.model,
|
gen.model,
|
||||||
packName,
|
packName,
|
||||||
count,
|
count,
|
||||||
intensity,
|
|
||||||
tone,
|
tone,
|
||||||
packDescription,
|
packDescription,
|
||||||
existingQuestions,
|
existingQuestions,
|
||||||
@@ -191,8 +188,7 @@ export async function generateQuestionsWithCouncil(
|
|||||||
const cherryPickResult = await performCherryPickSelection(
|
const cherryPickResult = await performCherryPickSelection(
|
||||||
successfulCandidates,
|
successfulCandidates,
|
||||||
packContext,
|
packContext,
|
||||||
count,
|
count
|
||||||
config
|
|
||||||
);
|
);
|
||||||
const reviewTime = Date.now() - startReview;
|
const reviewTime = Date.now() - startReview;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Question Generation Functions
|
// Question Generation Functions
|
||||||
// Generate questions for packs with various tone/intensity settings
|
// Generate questions for packs with various tone settings
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
import { getOpenAI, getModel, getTemperature } from '../client';
|
import { getOpenAI, getModel, getTemperature } from '../client';
|
||||||
import { INTENSITY_GUIDE_SHORT, TONE_INSTRUCTIONS, SYSTEM_MESSAGES, CORE_LANGUAGE_RULES } from '../config';
|
import { TONE_INSTRUCTIONS, SYSTEM_MESSAGES, CORE_LANGUAGE_RULES } from '../config';
|
||||||
import type { GeneratedQuestion, ToneLevel } from '../types';
|
import type { GeneratedQuestion, ToneLevel } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,7 +13,6 @@ import type { GeneratedQuestion, ToneLevel } from '../types';
|
|||||||
function buildQuestionPrompt(
|
function buildQuestionPrompt(
|
||||||
packName: string,
|
packName: string,
|
||||||
count: number,
|
count: number,
|
||||||
intensity: number | undefined,
|
|
||||||
tone: ToneLevel,
|
tone: ToneLevel,
|
||||||
packDescription?: string,
|
packDescription?: string,
|
||||||
existingQuestions?: string[],
|
existingQuestions?: string[],
|
||||||
@@ -38,12 +37,6 @@ function buildQuestionPrompt(
|
|||||||
? `\n\n<user_guidance>\n${sanitizedInspiration}\n</user_guidance>\n\nIncorporate the user guidance above into your generation where appropriate. Use it to inform the theme and style of questions.`
|
? `\n\n<user_guidance>\n${sanitizedInspiration}\n</user_guidance>\n\nIncorporate the user guidance above into your generation where appropriate. Use it to inform the theme and style of questions.`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const intensityInstruction = tone === 1
|
|
||||||
? 'All questions should be intensity level 1 (non-sexual bonding).'
|
|
||||||
: intensity
|
|
||||||
? `All questions should be at intensity level ${intensity}.`
|
|
||||||
: 'Vary the intensity levels appropriate for the tone.';
|
|
||||||
|
|
||||||
const toneInstruction = TONE_INSTRUCTIONS[tone];
|
const toneInstruction = TONE_INSTRUCTIONS[tone];
|
||||||
|
|
||||||
// Examples based on tone level
|
// Examples based on tone level
|
||||||
@@ -90,12 +83,7 @@ Generate ${count} unique questions for a couples' intimacy question pack called
|
|||||||
</task>
|
</task>
|
||||||
${descriptionContext}${inspirationInstruction}${existingQuestionsContext}
|
${descriptionContext}${inspirationInstruction}${existingQuestionsContext}
|
||||||
|
|
||||||
<intensity_guide>
|
|
||||||
${INTENSITY_GUIDE_SHORT}
|
|
||||||
</intensity_guide>
|
|
||||||
|
|
||||||
<instructions>
|
<instructions>
|
||||||
${intensityInstruction}
|
|
||||||
${toneInstruction}${explicitWarning}${crudeLangInstruction}
|
${toneInstruction}${explicitWarning}${crudeLangInstruction}
|
||||||
</instructions>
|
</instructions>
|
||||||
|
|
||||||
@@ -147,7 +135,6 @@ Return a JSON object with this exact structure:
|
|||||||
{
|
{
|
||||||
"text": string, // REQUIRED: 5-12 words, doer's perspective using "your partner"
|
"text": string, // REQUIRED: 5-12 words, doer's perspective using "your partner"
|
||||||
"partner_text": string|null, // REQUIRED for asymmetric, null for symmetric
|
"partner_text": string|null, // REQUIRED for asymmetric, null for symmetric
|
||||||
"intensity": 1-5, // REQUIRED: must match the intensity level guidelines
|
|
||||||
"requires_props": string[]|null, // Optional: items needed (e.g., ["blindfold", "massage oil"])
|
"requires_props": string[]|null, // Optional: items needed (e.g., ["blindfold", "massage oil"])
|
||||||
"inverse_pair_id": string|null, // REQUIRED for asymmetric pairs, null for symmetric
|
"inverse_pair_id": string|null, // REQUIRED for asymmetric pairs, null for symmetric
|
||||||
"location_type": "home"|"public"|"outdoors"|"travel"|"anywhere", // Optional
|
"location_type": "home"|"public"|"outdoors"|"travel"|"anywhere", // Optional
|
||||||
@@ -164,7 +151,6 @@ Return a JSON object with this exact structure:
|
|||||||
export async function generateQuestions(
|
export async function generateQuestions(
|
||||||
packName: string,
|
packName: string,
|
||||||
count: number = 10,
|
count: number = 10,
|
||||||
intensity?: number,
|
|
||||||
tone: ToneLevel = 3,
|
tone: ToneLevel = 3,
|
||||||
packDescription?: string,
|
packDescription?: string,
|
||||||
existingQuestions?: string[],
|
existingQuestions?: string[],
|
||||||
@@ -176,7 +162,6 @@ export async function generateQuestions(
|
|||||||
const prompt = buildQuestionPrompt(
|
const prompt = buildQuestionPrompt(
|
||||||
packName,
|
packName,
|
||||||
count,
|
count,
|
||||||
intensity,
|
|
||||||
tone,
|
tone,
|
||||||
packDescription,
|
packDescription,
|
||||||
existingQuestions,
|
existingQuestions,
|
||||||
@@ -212,7 +197,6 @@ export async function generateQuestionsWithModel(
|
|||||||
model: string,
|
model: string,
|
||||||
packName: string,
|
packName: string,
|
||||||
count: number,
|
count: number,
|
||||||
intensity: number | undefined,
|
|
||||||
tone: ToneLevel,
|
tone: ToneLevel,
|
||||||
packDescription: string | undefined,
|
packDescription: string | undefined,
|
||||||
existingQuestions: string[] | undefined,
|
existingQuestions: string[] | undefined,
|
||||||
@@ -225,7 +209,6 @@ export async function generateQuestionsWithModel(
|
|||||||
const prompt = buildQuestionPrompt(
|
const prompt = buildQuestionPrompt(
|
||||||
packName,
|
packName,
|
||||||
count,
|
count,
|
||||||
intensity,
|
|
||||||
tone,
|
tone,
|
||||||
packDescription,
|
packDescription,
|
||||||
existingQuestions,
|
existingQuestions,
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ export {
|
|||||||
getCouncilConfig,
|
getCouncilConfig,
|
||||||
initializeAiConfig,
|
initializeAiConfig,
|
||||||
TONE_LEVELS,
|
TONE_LEVELS,
|
||||||
INTENSITY_LEVELS,
|
|
||||||
INTENSITY_GUIDE,
|
|
||||||
INTENSITY_GUIDE_SHORT,
|
|
||||||
REVIEW_GUIDELINES,
|
REVIEW_GUIDELINES,
|
||||||
TONE_INSTRUCTIONS,
|
TONE_INSTRUCTIONS,
|
||||||
SYSTEM_MESSAGES,
|
SYSTEM_MESSAGES,
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export async function reviewGeneratedQuestions(
|
|||||||
index: i,
|
index: i,
|
||||||
text: q.text,
|
text: q.text,
|
||||||
partner_text: q.partner_text || null,
|
partner_text: q.partner_text || null,
|
||||||
intensity: q.intensity,
|
|
||||||
location_type: q.location_type,
|
location_type: q.location_type,
|
||||||
effort_level: q.effort_level,
|
effort_level: q.effort_level,
|
||||||
// Include targeting for review (may not always be present)
|
// Include targeting for review (may not always be present)
|
||||||
@@ -64,7 +63,7 @@ ${REVIEW_GUIDELINES}
|
|||||||
<verdict_rules>
|
<verdict_rules>
|
||||||
- PASS: All scores >= 7, no major issues
|
- PASS: All scores >= 7, no major issues
|
||||||
- FLAG: Any score 5-7, or minor issues worth noting (admin should review but can use)
|
- FLAG: Any score 5-7, or minor issues worth noting (admin should review but can use)
|
||||||
- REJECT: Any score < 5, or major violations (mixed anatomy, wrong intensity, severe guideline violation)
|
- REJECT: Any score < 5, or major violations (mixed anatomy, severe guideline violation)
|
||||||
</verdict_rules>
|
</verdict_rules>
|
||||||
|
|
||||||
<questions_to_review>
|
<questions_to_review>
|
||||||
@@ -83,13 +82,11 @@ ${JSON.stringify(questionsForReview, null, 2)}
|
|||||||
"guidelineCompliance": 1-10, // Criterion 1
|
"guidelineCompliance": 1-10, // Criterion 1
|
||||||
"creativity": 1-10, // Criterion 2
|
"creativity": 1-10, // Criterion 2
|
||||||
"clarity": 1-10, // Criterion 3
|
"clarity": 1-10, // Criterion 3
|
||||||
"intensityAccuracy": 1-10, // Criterion 4
|
"anatomicalConsistency": 1-10, // Criterion 4
|
||||||
"anatomicalConsistency": 1-10, // Criterion 5
|
"partnerTextQuality": 1-10, // Criterion 5 (10 if no partner_text needed)
|
||||||
"partnerTextQuality": 1-10, // Criterion 6 (10 if no partner_text needed)
|
"coupleTargeting": 1-10, // Criterion 6
|
||||||
"coupleTargeting": 1-10, // Criterion 7
|
"initiatorTargeting": 1-10 // Criterion 7 (10 if symmetric)
|
||||||
"initiatorTargeting": 1-10 // Criterion 8 (10 if symmetric)
|
|
||||||
},
|
},
|
||||||
"intensitySuggestion": number|null, // Suggested intensity (1-5) if current seems wrong, null if correct
|
|
||||||
"targetingSuggestions": { // Only include if targeting needs changes
|
"targetingSuggestions": { // Only include if targeting needs changes
|
||||||
"suggestedCoupleTargets": string[]|null, // ["male+male", "female+male", "female+female"] or subset
|
"suggestedCoupleTargets": string[]|null, // ["male+male", "female+male", "female+female"] or subset
|
||||||
"suggestedInitiator": string[]|null, // ["male", "female"] or null for any
|
"suggestedInitiator": string[]|null, // ["male", "female"] or null for any
|
||||||
@@ -99,7 +96,7 @@ ${JSON.stringify(questionsForReview, null, 2)}
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
Review ALL questions against ALL 8 criteria. Be thorough but fair - only flag/reject for genuine issues.
|
Review ALL questions against ALL 7 criteria. Be thorough but fair - only flag/reject for genuine issues.
|
||||||
</output_format>`;
|
</output_format>`;
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
const response = await openai.chat.completions.create({
|
||||||
@@ -132,18 +129,17 @@ Review ALL questions against ALL 8 criteria. Be thorough but fair - only flag/re
|
|||||||
const flagged = reviews.filter(r => r.verdict === 'flag').length;
|
const flagged = reviews.filter(r => r.verdict === 'flag').length;
|
||||||
const rejected = reviews.filter(r => r.verdict === 'reject').length;
|
const rejected = reviews.filter(r => r.verdict === 'reject').length;
|
||||||
|
|
||||||
// Calculate overall quality as weighted average of all 8 scores
|
// Calculate overall quality as weighted average of all 7 scores
|
||||||
// Weights: guidelineCompliance 20%, creativity 15%, clarity 15%, intensityAccuracy 15%,
|
// Weights: guidelineCompliance 25%, creativity 18%, clarity 18%,
|
||||||
// anatomicalConsistency 15%, partnerTextQuality 10%, coupleTargeting 5%, initiatorTargeting 5%
|
// anatomicalConsistency 17%, partnerTextQuality 12%, coupleTargeting 5%, initiatorTargeting 5%
|
||||||
let totalWeightedScore = 0;
|
let totalWeightedScore = 0;
|
||||||
let totalWeight = 0;
|
let totalWeight = 0;
|
||||||
const weights = {
|
const weights = {
|
||||||
guidelineCompliance: 0.20,
|
guidelineCompliance: 0.25,
|
||||||
creativity: 0.15,
|
creativity: 0.18,
|
||||||
clarity: 0.15,
|
clarity: 0.18,
|
||||||
intensityAccuracy: 0.15,
|
anatomicalConsistency: 0.17,
|
||||||
anatomicalConsistency: 0.15,
|
partnerTextQuality: 0.12,
|
||||||
partnerTextQuality: 0.10,
|
|
||||||
coupleTargeting: 0.05,
|
coupleTargeting: 0.05,
|
||||||
initiatorTargeting: 0.05,
|
initiatorTargeting: 0.05,
|
||||||
};
|
};
|
||||||
@@ -200,7 +196,6 @@ export async function selectBestGeneration(
|
|||||||
index: qi,
|
index: qi,
|
||||||
text: q.text,
|
text: q.text,
|
||||||
partner_text: q.partner_text || null,
|
partner_text: q.partner_text || null,
|
||||||
intensity: q.intensity,
|
|
||||||
allowed_couple_genders: (q as { allowed_couple_genders?: string[] | null }).allowed_couple_genders || null,
|
allowed_couple_genders: (q as { allowed_couple_genders?: string[] | null }).allowed_couple_genders || null,
|
||||||
target_user_genders: (q as { target_user_genders?: string[] | null }).target_user_genders || null,
|
target_user_genders: (q as { target_user_genders?: string[] | null }).target_user_genders || null,
|
||||||
})),
|
})),
|
||||||
@@ -235,9 +230,8 @@ Ranked by importance (approximate weights):
|
|||||||
1. Overall quality and creativity of questions (30%)
|
1. Overall quality and creativity of questions (30%)
|
||||||
2. Variety and uniqueness within the set (20%)
|
2. Variety and uniqueness within the set (20%)
|
||||||
3. Adherence to pack theme and tone (20%)
|
3. Adherence to pack theme and tone (20%)
|
||||||
4. Proper intensity grading (15%)
|
4. Correct use of "your partner" language (15%)
|
||||||
5. Correct use of "your partner" language (10%)
|
5. Quality of partner_text for asymmetric questions (10%)
|
||||||
6. Quality of partner_text for asymmetric questions (5%)
|
|
||||||
</selection_criteria>
|
</selection_criteria>
|
||||||
|
|
||||||
<output_format>
|
<output_format>
|
||||||
@@ -254,13 +248,11 @@ Ranked by importance (approximate weights):
|
|||||||
"guidelineCompliance": 1-10,
|
"guidelineCompliance": 1-10,
|
||||||
"creativity": 1-10,
|
"creativity": 1-10,
|
||||||
"clarity": 1-10,
|
"clarity": 1-10,
|
||||||
"intensityAccuracy": 1-10,
|
|
||||||
"anatomicalConsistency": 1-10,
|
"anatomicalConsistency": 1-10,
|
||||||
"partnerTextQuality": 1-10,
|
"partnerTextQuality": 1-10,
|
||||||
"coupleTargeting": 1-10,
|
"coupleTargeting": 1-10,
|
||||||
"initiatorTargeting": 1-10
|
"initiatorTargeting": 1-10
|
||||||
},
|
},
|
||||||
"intensitySuggestion": number|null,
|
|
||||||
"targetingSuggestions": {
|
"targetingSuggestions": {
|
||||||
"suggestedCoupleTargets": string[]|null,
|
"suggestedCoupleTargets": string[]|null,
|
||||||
"suggestedInitiator": string[]|null,
|
"suggestedInitiator": string[]|null,
|
||||||
@@ -270,7 +262,7 @@ Ranked by importance (approximate weights):
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
Select the best set and review ALL questions in that set against ALL 8 criteria.
|
Select the best set and review ALL questions in that set against ALL 7 criteria.
|
||||||
</output_format>`;
|
</output_format>`;
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
const response = await openai.chat.completions.create({
|
||||||
@@ -300,7 +292,7 @@ Select the best set and review ALL questions in that set against ALL 8 criteria.
|
|||||||
const reviews: QuestionReview[] = parsed.reviews || [];
|
const reviews: QuestionReview[] = parsed.reviews || [];
|
||||||
const reasoning = parsed.reasoning || 'No reasoning provided';
|
const reasoning = parsed.reasoning || 'No reasoning provided';
|
||||||
|
|
||||||
// Calculate summary with weighted average of all 8 scores
|
// Calculate summary with weighted average of all 7 scores
|
||||||
const passed = reviews.filter(r => r.verdict === 'pass').length;
|
const passed = reviews.filter(r => r.verdict === 'pass').length;
|
||||||
const flagged = reviews.filter(r => r.verdict === 'flag').length;
|
const flagged = reviews.filter(r => r.verdict === 'flag').length;
|
||||||
const rejected = reviews.filter(r => r.verdict === 'reject').length;
|
const rejected = reviews.filter(r => r.verdict === 'reject').length;
|
||||||
@@ -308,12 +300,11 @@ Select the best set and review ALL questions in that set against ALL 8 criteria.
|
|||||||
let totalWeightedScore = 0;
|
let totalWeightedScore = 0;
|
||||||
let totalWeight = 0;
|
let totalWeight = 0;
|
||||||
const weights = {
|
const weights = {
|
||||||
guidelineCompliance: 0.20,
|
guidelineCompliance: 0.25,
|
||||||
creativity: 0.15,
|
creativity: 0.18,
|
||||||
clarity: 0.15,
|
clarity: 0.18,
|
||||||
intensityAccuracy: 0.15,
|
anatomicalConsistency: 0.17,
|
||||||
anatomicalConsistency: 0.15,
|
partnerTextQuality: 0.12,
|
||||||
partnerTextQuality: 0.10,
|
|
||||||
coupleTargeting: 0.05,
|
coupleTargeting: 0.05,
|
||||||
initiatorTargeting: 0.05,
|
initiatorTargeting: 0.05,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export interface CouncilConfig {
|
|||||||
reviewerModel: string;
|
reviewerModel: string;
|
||||||
reviewerTemperature?: number;
|
reviewerTemperature?: number;
|
||||||
selectionMode: CouncilSelectionMode;
|
selectionMode: CouncilSelectionMode;
|
||||||
cherryPickEnsureIntensityDistribution: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -71,7 +70,6 @@ export interface QuestionReviewScores {
|
|||||||
guidelineCompliance: number;
|
guidelineCompliance: number;
|
||||||
creativity: number;
|
creativity: number;
|
||||||
clarity: number;
|
clarity: number;
|
||||||
intensityAccuracy: number;
|
|
||||||
anatomicalConsistency: number;
|
anatomicalConsistency: number;
|
||||||
partnerTextQuality: number;
|
partnerTextQuality: number;
|
||||||
coupleTargeting: number;
|
coupleTargeting: number;
|
||||||
@@ -85,7 +83,6 @@ export interface QuestionReview {
|
|||||||
suggestions?: string;
|
suggestions?: string;
|
||||||
scores: QuestionReviewScores;
|
scores: QuestionReviewScores;
|
||||||
// Additional detailed feedback
|
// Additional detailed feedback
|
||||||
intensitySuggestion?: number | null; // Suggested intensity if current seems wrong
|
|
||||||
targetingSuggestions?: {
|
targetingSuggestions?: {
|
||||||
suggestedCoupleTargets?: string[] | null;
|
suggestedCoupleTargets?: string[] | null;
|
||||||
suggestedInitiator?: string[] | null;
|
suggestedInitiator?: string[] | null;
|
||||||
@@ -173,7 +170,6 @@ export interface CherryPickEvaluation {
|
|||||||
guidelineCompliance: number;
|
guidelineCompliance: number;
|
||||||
creativity: number;
|
creativity: number;
|
||||||
clarity: number;
|
clarity: number;
|
||||||
intensityAccuracy: number;
|
|
||||||
uniqueness: number;
|
uniqueness: number;
|
||||||
};
|
};
|
||||||
isDuplicate: boolean;
|
isDuplicate: boolean;
|
||||||
@@ -187,7 +183,6 @@ export interface CherryPickResult {
|
|||||||
evaluations: CherryPickEvaluation[];
|
evaluations: CherryPickEvaluation[];
|
||||||
poolSize: number;
|
poolSize: number;
|
||||||
duplicatesRemoved: number;
|
duplicatesRemoved: number;
|
||||||
intensityDistribution: Record<number, number>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -206,9 +201,6 @@ export interface TextAnalysis {
|
|||||||
suggested_text: string;
|
suggested_text: string;
|
||||||
suggested_partner_text: string | null;
|
suggested_partner_text: string | null;
|
||||||
reason: string;
|
reason: string;
|
||||||
// Intensity suggestion (only if AI thinks current is wrong)
|
|
||||||
suggested_intensity?: number | null;
|
|
||||||
intensity_reason?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeletionAnalysis {
|
export interface DeletionAnalysis {
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ export function AiSettingsPage() {
|
|||||||
council_reviewer_model: config.council_reviewer_model || '',
|
council_reviewer_model: config.council_reviewer_model || '',
|
||||||
council_reviewer_temperature: config.council_reviewer_temperature,
|
council_reviewer_temperature: config.council_reviewer_temperature,
|
||||||
council_selection_mode: config.council_selection_mode || 'whole_set',
|
council_selection_mode: config.council_selection_mode || 'whole_set',
|
||||||
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
|
|
||||||
classifier_enabled: config.classifier_enabled ?? true,
|
classifier_enabled: config.classifier_enabled ?? true,
|
||||||
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
||||||
classifier_temperature: config.classifier_temperature,
|
classifier_temperature: config.classifier_temperature,
|
||||||
@@ -183,7 +182,6 @@ export function AiSettingsPage() {
|
|||||||
council_reviewer_model: config.council_reviewer_model || '',
|
council_reviewer_model: config.council_reviewer_model || '',
|
||||||
council_reviewer_temperature: config.council_reviewer_temperature,
|
council_reviewer_temperature: config.council_reviewer_temperature,
|
||||||
council_selection_mode: config.council_selection_mode || 'whole_set',
|
council_selection_mode: config.council_selection_mode || 'whole_set',
|
||||||
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
|
|
||||||
classifier_enabled: config.classifier_enabled ?? true,
|
classifier_enabled: config.classifier_enabled ?? true,
|
||||||
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
||||||
classifier_temperature: config.classifier_temperature,
|
classifier_temperature: config.classifier_temperature,
|
||||||
@@ -851,24 +849,6 @@ export function AiSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cherry-pick intensity option */}
|
|
||||||
{formData.council_selection_mode === 'cherry_pick' && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label className="text-sm">Ensure Intensity Variety</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Balance across intensity levels 1-5
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={formData.cherry_pick_ensure_intensity_distribution ?? true}
|
|
||||||
onCheckedChange={(checked: boolean) =>
|
|
||||||
handleChange('cherry_pick_ensure_intensity_distribution', checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Uses {generators.length + 1} API calls ({generators.length} generator{generators.length > 1 ? 's' : ''} + reviewer)
|
Uses {generators.length + 1} API calls ({generators.length} generator{generators.length > 1 ? 's' : ''} + reviewer)
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
@@ -30,7 +29,6 @@ export function AppSettingsPage() {
|
|||||||
setFormData({
|
setFormData({
|
||||||
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
||||||
daily_response_limit: config.daily_response_limit ?? 0,
|
daily_response_limit: config.daily_response_limit ?? 0,
|
||||||
couple_intensity_gate_enabled: config.couple_intensity_gate_enabled ?? false,
|
|
||||||
});
|
});
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
}
|
}
|
||||||
@@ -63,7 +61,6 @@ export function AppSettingsPage() {
|
|||||||
setFormData({
|
setFormData({
|
||||||
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
||||||
daily_response_limit: config.daily_response_limit ?? 0,
|
daily_response_limit: config.daily_response_limit ?? 0,
|
||||||
couple_intensity_gate_enabled: config.couple_intensity_gate_enabled ?? false,
|
|
||||||
});
|
});
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
}
|
}
|
||||||
@@ -160,19 +157,6 @@ export function AppSettingsPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label>Couple Comfort Zone Gate</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
When enabled, both recommended questions and answer-gap blocking use the lower of the two partners' comfort zones.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={Boolean(formData.couple_intensity_gate_enabled)}
|
|
||||||
onCheckedChange={(checked) => handleChange('couple_intensity_gate_enabled', checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="threshold">Answer Gap Threshold</Label>
|
<Label htmlFor="threshold">Answer Gap Threshold</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -97,10 +97,10 @@ export function AuditLogsPage() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-40">Time</TableHead>
|
<TableHead className="w-40">Time</TableHead>
|
||||||
<TableHead className="w-32">Admin</TableHead>
|
<TableHead className="hidden md:table-cell w-32">Admin</TableHead>
|
||||||
<TableHead className="w-24">Action</TableHead>
|
<TableHead className="hidden md:table-cell w-24">Action</TableHead>
|
||||||
<TableHead>Table</TableHead>
|
<TableHead className="hidden lg:table-cell">Table</TableHead>
|
||||||
<TableHead>Changes</TableHead>
|
<TableHead className="hidden lg:table-cell">Changes</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -111,47 +111,63 @@ export function AuditLogsPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log) => (
|
logs.map((log) => {
|
||||||
<TableRow key={log.id}>
|
const actionBadge = (
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<Badge className={`${actionColors[log.action] || 'bg-gray-500'} text-white`}>
|
||||||
{format(new Date(log.created_at), 'MMM d, h:mm a')}
|
{log.action}
|
||||||
</TableCell>
|
</Badge>
|
||||||
<TableCell>
|
);
|
||||||
<div className="text-sm font-medium">{log.admin_username || 'Unknown'}</div>
|
|
||||||
<div className="text-xs text-muted-foreground capitalize">{log.admin_role?.replace('_', ' ')}</div>
|
const changesContent = log.action === 'DELETE' && log.old_values ? (
|
||||||
</TableCell>
|
<code className="text-xs bg-red-500/20 text-red-300 px-2 py-1 rounded">
|
||||||
<TableCell>
|
Deleted: {JSON.stringify(log.old_values).substring(0, 100)}...
|
||||||
<Badge className={`${actionColors[log.action] || 'bg-gray-500'} text-white`}>
|
</code>
|
||||||
{log.action}
|
) : log.action === 'INSERT' && log.new_values ? (
|
||||||
</Badge>
|
<code className="text-xs bg-green-500/20 text-green-300 px-2 py-1 rounded">
|
||||||
</TableCell>
|
Created: {JSON.stringify(log.new_values).substring(0, 100)}...
|
||||||
<TableCell className="font-mono text-sm">
|
</code>
|
||||||
{log.table_name}
|
) : log.action === 'UPDATE' && log.changed_fields ? (
|
||||||
</TableCell>
|
<code className="text-xs bg-blue-500/20 text-blue-300 px-2 py-1 rounded">
|
||||||
<TableCell className="max-w-md">
|
Changed: {log.changed_fields.join(', ')}
|
||||||
{log.action === 'DELETE' && log.old_values && (
|
</code>
|
||||||
<code className="text-xs bg-red-500/20 text-red-300 px-2 py-1 rounded">
|
) : log.action === 'UPDATE' ? (
|
||||||
Deleted: {JSON.stringify(log.old_values).substring(0, 100)}...
|
<code className="text-xs bg-blue-500/20 text-blue-300 px-2 py-1 rounded">
|
||||||
</code>
|
Updated record
|
||||||
)}
|
</code>
|
||||||
{log.action === 'INSERT' && log.new_values && (
|
) : null;
|
||||||
<code className="text-xs bg-green-500/20 text-green-300 px-2 py-1 rounded">
|
|
||||||
Created: {JSON.stringify(log.new_values).substring(0, 100)}...
|
return (
|
||||||
</code>
|
<TableRow key={log.id}>
|
||||||
)}
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{log.action === 'UPDATE' && log.changed_fields && (
|
{format(new Date(log.created_at), 'MMM d, h:mm a')}
|
||||||
<code className="text-xs bg-blue-500/20 text-blue-300 px-2 py-1 rounded">
|
<div className="mt-2 space-y-1 text-xs text-muted-foreground md:hidden">
|
||||||
Changed: {log.changed_fields.join(', ')}
|
<div className="flex items-center gap-2">
|
||||||
</code>
|
<span className="font-medium text-foreground">{log.admin_username || 'Unknown'}</span>
|
||||||
)}
|
<span className="capitalize">{log.admin_role?.replace('_', ' ')}</span>
|
||||||
{log.action === 'UPDATE' && !log.changed_fields && (
|
</div>
|
||||||
<code className="text-xs bg-blue-500/20 text-blue-300 px-2 py-1 rounded">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
Updated record
|
{actionBadge}
|
||||||
</code>
|
<span className="font-mono">{log.table_name}</span>
|
||||||
)}
|
</div>
|
||||||
</TableCell>
|
{changesContent && <div>{changesContent}</div>}
|
||||||
</TableRow>
|
</div>
|
||||||
))
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
<div className="text-sm font-medium">{log.admin_username || 'Unknown'}</div>
|
||||||
|
<div className="text-xs text-muted-foreground capitalize">{log.admin_role?.replace('_', ' ')}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
{actionBadge}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell font-mono text-sm">
|
||||||
|
{log.table_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell max-w-md">
|
||||||
|
{changesContent}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
@@ -239,13 +239,13 @@ export function FeedbackPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">User Feedback</h1>
|
<h1 className="text-3xl font-bold tracking-tight">User Feedback</h1>
|
||||||
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Review and respond to user feedback submissions
|
Review and respond to user feedback submissions
|
||||||
</p>
|
</p>
|
||||||
@@ -253,20 +253,20 @@ export function FeedbackPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||||
<div className="relative flex-1 max-w-sm">
|
<div className="relative w-full md:flex-1 md:max-w-sm">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search feedback..."
|
placeholder="Search feedback..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-full md:w-[150px]">
|
||||||
<SelectValue placeholder="Type" />
|
<SelectValue placeholder="Type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Types</SelectItem>
|
<SelectItem value="all">All Types</SelectItem>
|
||||||
<SelectItem value="bug">Bug</SelectItem>
|
<SelectItem value="bug">Bug</SelectItem>
|
||||||
@@ -274,11 +274,11 @@ export function FeedbackPage() {
|
|||||||
<SelectItem value="general">General</SelectItem>
|
<SelectItem value="general">General</SelectItem>
|
||||||
<SelectItem value="question">Question</SelectItem>
|
<SelectItem value="question">Question</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-full md:w-[150px]">
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder="Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
<SelectItem value="new">New</SelectItem>
|
<SelectItem value="new">New</SelectItem>
|
||||||
@@ -295,12 +295,12 @@ export function FeedbackPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Type</TableHead>
|
<TableHead className="hidden md:table-cell">Type</TableHead>
|
||||||
<TableHead>Title</TableHead>
|
<TableHead>Title</TableHead>
|
||||||
<TableHead>User</TableHead>
|
<TableHead className="hidden lg:table-cell">User</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead className="hidden md:table-cell">Status</TableHead>
|
||||||
<TableHead>Created</TableHead>
|
<TableHead className="hidden lg:table-cell">Created</TableHead>
|
||||||
<TableHead className="w-[100px]">Actions</TableHead>
|
<TableHead className="hidden md:table-cell w-[100px]">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -321,27 +321,35 @@ export function FeedbackPage() {
|
|||||||
) : (
|
) : (
|
||||||
paginatedFeedback.map((item) => (
|
paginatedFeedback.map((item) => (
|
||||||
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleOpenDetail(item)}>
|
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleOpenDetail(item)}>
|
||||||
<TableCell>{getTypeBadge(item.type)}</TableCell>
|
<TableCell className="hidden md:table-cell">{getTypeBadge(item.type)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="max-w-[300px]">
|
<div className="md:max-w-[300px]">
|
||||||
<div className="font-medium truncate">{item.title}</div>
|
<div className="font-medium truncate">{item.title}</div>
|
||||||
<div className="text-sm text-muted-foreground truncate">
|
<div className="text-sm text-muted-foreground truncate">
|
||||||
{item.description.slice(0, 80)}
|
{item.description.slice(0, 80)}
|
||||||
{item.description.length > 80 && '...'}
|
{item.description.length > 80 && '...'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground md:hidden">
|
||||||
|
{getTypeBadge(item.type)}
|
||||||
|
{getStatusBadge(item.status)}
|
||||||
|
<span className="truncate max-w-[220px]">
|
||||||
|
{item.profile?.name || item.profile?.email || 'Unknown user'}
|
||||||
|
</span>
|
||||||
|
<span>{format(new Date(item.created_at), 'MMM d, yyyy')}</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="hidden lg:table-cell">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div className="font-medium">{item.profile?.name || 'Unknown'}</div>
|
<div className="font-medium">{item.profile?.name || 'Unknown'}</div>
|
||||||
<div className="text-muted-foreground">{item.profile?.email}</div>
|
<div className="text-muted-foreground">{item.profile?.email}</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{getStatusBadge(item.status)}</TableCell>
|
<TableCell className="hidden md:table-cell">{getStatusBadge(item.status)}</TableCell>
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
<TableCell className="hidden lg:table-cell text-muted-foreground text-sm">
|
||||||
{format(new Date(item.created_at), 'MMM d, yyyy')}
|
{format(new Date(item.created_at), 'MMM d, yyyy')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="hidden md:table-cell">
|
||||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleOpenDetail(item); }}>
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleOpenDetail(item); }}>
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -435,7 +435,7 @@ export function FlaggedMessagesPage() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Time</TableHead>
|
<TableHead>Time</TableHead>
|
||||||
<TableHead>Reason</TableHead>
|
<TableHead>Reason</TableHead>
|
||||||
<TableHead>Type</TableHead>
|
<TableHead className="hidden md:table-cell">Type</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -460,48 +460,57 @@ export function FlaggedMessagesPage() {
|
|||||||
<Badge variant="destructive" className="bg-red-500/10 text-red-500 hover:bg-red-500/20 border-0">
|
<Badge variant="destructive" className="bg-red-500/10 text-red-500 hover:bg-red-500/20 border-0">
|
||||||
{message.flag_reason || 'Unknown'}
|
{message.flag_reason || 'Unknown'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground md:hidden">
|
||||||
|
{message.media_path ? (
|
||||||
|
<Badge variant="outline">{message.media_type || 'Media'}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Text</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="hidden md:table-cell">
|
||||||
{message.media_path ? (
|
{message.media_path ? (
|
||||||
<Badge variant="outline">{message.media_type || 'Media'}</Badge>
|
<Badge variant="outline">{message.media_type || 'Media'}</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary">Text</Badge>
|
<Badge variant="secondary">Text</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right space-x-2">
|
<TableCell className="text-right">
|
||||||
<Button
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleViewAiMessage(message)}
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4 mr-2" />
|
|
||||||
Review
|
|
||||||
</Button>
|
|
||||||
{message.media_path && message.media_type !== 'video' && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDescribeInline(message)}
|
onClick={() => handleViewAiMessage(message)}
|
||||||
disabled={describingInline === message.id}
|
|
||||||
>
|
>
|
||||||
{describingInline === message.id ? (
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
Review
|
||||||
) : (
|
|
||||||
<Sparkles className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Describe
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{message.media_path && message.media_type !== 'video' && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
onClick={() => handleDescribeInline(message)}
|
||||||
onClick={() => handleMarkSafe(message.id)}
|
disabled={describingInline === message.id}
|
||||||
disabled={processing === message.id}
|
>
|
||||||
>
|
{describingInline === message.id ? (
|
||||||
<CheckCircle className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||||
Mark Safe
|
) : (
|
||||||
</Button>
|
<Sparkles className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Describe
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||||
|
onClick={() => handleMarkSafe(message.id)}
|
||||||
|
disabled={processing === message.id}
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4 mr-2" />
|
||||||
|
Mark Safe
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{inlineDescriptions[message.id] && (
|
{inlineDescriptions[message.id] && (
|
||||||
@@ -550,9 +559,9 @@ export function FlaggedMessagesPage() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Time</TableHead>
|
<TableHead>Time</TableHead>
|
||||||
<TableHead>Reporter</TableHead>
|
<TableHead className="hidden md:table-cell">Reporter</TableHead>
|
||||||
<TableHead>Reason</TableHead>
|
<TableHead>Reason</TableHead>
|
||||||
<TableHead>Type</TableHead>
|
<TableHead className="hidden md:table-cell">Type</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -572,50 +581,62 @@ export function FlaggedMessagesPage() {
|
|||||||
<TableCell className="font-mono text-sm">
|
<TableCell className="font-mono text-sm">
|
||||||
{format(new Date(report.created_at), 'MMM d, HH:mm')}
|
{format(new Date(report.created_at), 'MMM d, HH:mm')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">
|
<TableCell className="hidden md:table-cell text-sm">
|
||||||
{report.reporter_profile?.name || report.reporter_profile?.email || 'Unknown'}
|
{report.reporter_profile?.name || report.reporter_profile?.email || 'Unknown'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline" className="bg-orange-500/10 text-orange-600 border-orange-200">
|
<Badge variant="outline" className="bg-orange-500/10 text-orange-600 border-orange-200">
|
||||||
{REASON_LABELS[report.reason] || report.reason}
|
{REASON_LABELS[report.reason] || report.reason}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground md:hidden">
|
||||||
|
<span className="truncate max-w-[220px]">
|
||||||
|
{report.reporter_profile?.name || report.reporter_profile?.email || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
{report.message?.media_path ? (
|
||||||
|
<Badge variant="outline">{report.message.media_type || 'Media'}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Text</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="hidden md:table-cell">
|
||||||
{report.message?.media_path ? (
|
{report.message?.media_path ? (
|
||||||
<Badge variant="outline">{report.message.media_type || 'Media'}</Badge>
|
<Badge variant="outline">{report.message.media_type || 'Media'}</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary">Text</Badge>
|
<Badge variant="secondary">Text</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right space-x-2">
|
<TableCell className="text-right">
|
||||||
<Button
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => handleViewReport(report)}
|
size="sm"
|
||||||
>
|
onClick={() => handleViewReport(report)}
|
||||||
<Eye className="h-4 w-4 mr-2" />
|
>
|
||||||
Review
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Review
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
size="sm"
|
||||||
onClick={() => handleDismissReport(report.id)}
|
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||||
disabled={processing === report.id}
|
onClick={() => handleDismissReport(report.id)}
|
||||||
>
|
disabled={processing === report.id}
|
||||||
<XCircle className="h-4 w-4 mr-2" />
|
>
|
||||||
Dismiss
|
<XCircle className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Dismiss
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
size="sm"
|
||||||
onClick={() => handleReviewReport(report.id)}
|
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||||
disabled={processing === report.id}
|
onClick={() => handleReviewReport(report.id)}
|
||||||
>
|
disabled={processing === report.id}
|
||||||
<CheckCircle className="h-4 w-4 mr-2" />
|
>
|
||||||
Mark Reviewed
|
<CheckCircle className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Mark Reviewed
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -302,31 +302,31 @@ export function RedemptionCodesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Redemption Codes</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Redemption Codes</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Generate and manage codes that grant premium access
|
Generate and manage codes that grant premium access
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<div className="relative w-64">
|
<div className="relative w-full sm:w-64">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search codes..."
|
placeholder="Search codes..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button onClick={handleOpenAddDialog}>
|
<Button onClick={handleOpenAddDialog} className="w-full sm:w-auto">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Generate Code
|
Generate Code
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Generate Redemption Code</DialogTitle>
|
<DialogTitle>Generate Redemption Code</DialogTitle>
|
||||||
@@ -335,14 +335,14 @@ export function RedemptionCodesPage() {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Code</Label>
|
<Label>Code</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<Input
|
<Input
|
||||||
value={formData.code}
|
value={formData.code}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, code: e.target.value.toUpperCase() }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, code: e.target.value.toUpperCase() }))}
|
||||||
placeholder="e.g., PREMIUM2024"
|
placeholder="e.g., PREMIUM2024"
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -399,12 +399,12 @@ export function RedemptionCodesPage() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Code</TableHead>
|
<TableHead>Code</TableHead>
|
||||||
<TableHead>Description</TableHead>
|
<TableHead className="hidden lg:table-cell">Description</TableHead>
|
||||||
<TableHead>Usage</TableHead>
|
<TableHead className="hidden md:table-cell">Usage</TableHead>
|
||||||
<TableHead>Expires</TableHead>
|
<TableHead className="hidden lg:table-cell">Expires</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead className="hidden md:table-cell">Status</TableHead>
|
||||||
<TableHead>Created</TableHead>
|
<TableHead className="hidden xl:table-cell">Created</TableHead>
|
||||||
<TableHead className="w-[150px]">Actions</TableHead>
|
<TableHead className="hidden md:table-cell w-[150px]">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -421,70 +421,112 @@ export function RedemptionCodesPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredCodes.map((code) => (
|
filteredCodes.map((code) => {
|
||||||
<TableRow key={code.id}>
|
const statusBadge = getStatusBadge(code);
|
||||||
<TableCell>
|
const expiresLabel = code.expires_at
|
||||||
<div className="flex items-center gap-2">
|
? format(new Date(code.expires_at), 'MMM d, yyyy HH:mm')
|
||||||
<code className="font-mono font-medium text-sm bg-muted px-2 py-1 rounded">
|
: 'Never';
|
||||||
{code.code}
|
|
||||||
</code>
|
return (
|
||||||
<Button
|
<TableRow key={code.id}>
|
||||||
variant="ghost"
|
<TableCell>
|
||||||
size="icon"
|
<div className="flex items-center gap-2">
|
||||||
className="h-6 w-6"
|
<code className="font-mono font-medium text-sm bg-muted px-2 py-1 rounded">
|
||||||
onClick={() => copyToClipboard(code.code)}
|
{code.code}
|
||||||
title="Copy code"
|
</code>
|
||||||
>
|
<Button
|
||||||
<Copy className="h-3 w-3" />
|
variant="ghost"
|
||||||
</Button>
|
size="icon"
|
||||||
</div>
|
className="h-6 w-6"
|
||||||
</TableCell>
|
onClick={() => copyToClipboard(code.code)}
|
||||||
<TableCell className="text-muted-foreground">
|
title="Copy code"
|
||||||
{code.description || '—'}
|
>
|
||||||
</TableCell>
|
<Copy className="h-3 w-3" />
|
||||||
<TableCell>
|
</Button>
|
||||||
<span className="font-medium">{code.current_uses}</span>
|
</div>
|
||||||
<span className="text-muted-foreground"> / {code.max_uses}</span>
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground md:hidden">
|
||||||
</TableCell>
|
{statusBadge}
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
<span>
|
||||||
{code.expires_at
|
{code.current_uses} / {code.max_uses} uses
|
||||||
? format(new Date(code.expires_at), 'MMM d, yyyy HH:mm')
|
</span>
|
||||||
: 'Never'}
|
<span>Expires {expiresLabel}</span>
|
||||||
</TableCell>
|
{code.description && <span className="truncate max-w-[220px]">{code.description}</span>}
|
||||||
<TableCell>
|
</div>
|
||||||
{getStatusBadge(code)}
|
<div className="mt-3 flex flex-wrap items-center gap-2 md:hidden">
|
||||||
</TableCell>
|
<Button
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
variant="outline"
|
||||||
{format(new Date(code.created_at), 'MMM d, yyyy')}
|
size="sm"
|
||||||
</TableCell>
|
onClick={() => handleViewRedemptions(code)}
|
||||||
<TableCell>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<Users className="h-3 w-3 mr-1" />
|
||||||
<Button
|
Redemptions
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
<div className="flex items-center gap-2 rounded-md border px-2 py-1">
|
||||||
onClick={() => handleViewRedemptions(code)}
|
<Switch
|
||||||
title="View redemptions"
|
checked={code.is_active}
|
||||||
>
|
onCheckedChange={() => handleToggleActive(code)}
|
||||||
<Users className="h-4 w-4" />
|
title={code.is_active ? "Deactivate" : "Activate"}
|
||||||
</Button>
|
/>
|
||||||
<Switch
|
<span className="text-xs text-muted-foreground">
|
||||||
checked={code.is_active}
|
{code.is_active ? 'Active' : 'Inactive'}
|
||||||
onCheckedChange={() => handleToggleActive(code)}
|
</span>
|
||||||
title={code.is_active ? "Deactivate" : "Activate"}
|
</div>
|
||||||
/>
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="icon"
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
onClick={() => handleDeleteCode(code)}
|
||||||
onClick={() => handleDeleteCode(code)}
|
>
|
||||||
title="Delete code"
|
<Trash2 className="h-3 w-3 mr-1" />
|
||||||
>
|
Delete
|
||||||
<Trash2 className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="hidden lg:table-cell text-muted-foreground">
|
||||||
</TableRow>
|
{code.description || '—'}
|
||||||
))
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
<span className="font-medium">{code.current_uses}</span>
|
||||||
|
<span className="text-muted-foreground"> / {code.max_uses}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell text-muted-foreground text-sm">
|
||||||
|
{expiresLabel}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
{statusBadge}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden xl:table-cell text-muted-foreground text-sm">
|
||||||
|
{format(new Date(code.created_at), 'MMM d, yyyy')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleViewRedemptions(code)}
|
||||||
|
title="View redemptions"
|
||||||
|
>
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Switch
|
||||||
|
checked={code.is_active}
|
||||||
|
onCheckedChange={() => handleToggleActive(code)}
|
||||||
|
title={code.is_active ? "Deactivate" : "Activate"}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleDeleteCode(code)}
|
||||||
|
title="Delete code"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
@@ -440,7 +440,7 @@ export function UserActivityPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<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">
|
||||||
@@ -457,7 +457,7 @@ export function UserActivityPage() {
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as ActivityTab)}>
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as ActivityTab)}>
|
||||||
<TabsList className="grid w-full grid-cols-4 max-w-[600px]">
|
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-4 max-w-[600px]">
|
||||||
<TabsTrigger value="responses" className="gap-2">
|
<TabsTrigger value="responses" className="gap-2">
|
||||||
<ThumbsUp className="h-4 w-4" />
|
<ThumbsUp className="h-4 w-4" />
|
||||||
Responses
|
Responses
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ export function AdminsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Admin Management</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Admin Management</h1>
|
||||||
@@ -312,8 +312,8 @@ export function AdminsPage() {
|
|||||||
Manage system administrators and their roles
|
Manage system administrators and their roles
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<div className="relative w-64">
|
<div className="relative w-full sm:w-64">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search admins..."
|
placeholder="Search admins..."
|
||||||
@@ -509,10 +509,10 @@ export function AdminsPage() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Admin User</TableHead>
|
<TableHead>Admin User</TableHead>
|
||||||
<TableHead>Email</TableHead>
|
<TableHead className="hidden md:table-cell">Email</TableHead>
|
||||||
<TableHead>Role / Permissions</TableHead>
|
<TableHead className="hidden lg:table-cell">Role / Permissions</TableHead>
|
||||||
<TableHead>Added On</TableHead>
|
<TableHead className="hidden xl:table-cell">Added On</TableHead>
|
||||||
<TableHead className="w-[120px]">Actions</TableHead>
|
<TableHead className="hidden md:table-cell w-[120px]">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -529,82 +529,128 @@ export function AdminsPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
paginatedAdmins.map((admin) => (
|
paginatedAdmins.map((admin) => {
|
||||||
<TableRow key={admin.user_id}>
|
const isSelf = admin.user_id === currentUser?.id;
|
||||||
<TableCell>
|
const mobileRoleBadge = admin.role === 'super_admin' ? (
|
||||||
<div className="flex items-center gap-3">
|
<Badge variant="default">
|
||||||
<Avatar className="h-9 w-9">
|
<Shield className="h-3 w-3 mr-1" />
|
||||||
<AvatarImage src={admin.profile?.avatar_url || undefined} />
|
Super Admin
|
||||||
<AvatarFallback>
|
</Badge>
|
||||||
{admin.profile?.name?.charAt(0).toUpperCase() || 'A'}
|
) : (
|
||||||
</AvatarFallback>
|
<Badge variant="secondary">
|
||||||
</Avatar>
|
{admin.permissions.length > 0
|
||||||
<span className="font-medium">
|
? `${admin.permissions.length} permission${admin.permissions.length !== 1 ? 's' : ''}`
|
||||||
{admin.profile?.name || 'Unknown User'}
|
: 'No permissions'}
|
||||||
</span>
|
</Badge>
|
||||||
{admin.user_id === currentUser?.id && (
|
);
|
||||||
<Badge variant="outline" className="ml-2">You</Badge>
|
|
||||||
)}
|
return (
|
||||||
</div>
|
<TableRow key={admin.user_id}>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
{admin.profile?.email || '—'}
|
<Avatar className="h-9 w-9">
|
||||||
</TableCell>
|
<AvatarImage src={admin.profile?.avatar_url || undefined} />
|
||||||
<TableCell>
|
<AvatarFallback>
|
||||||
{admin.role === 'super_admin' ? (
|
{admin.profile?.name?.charAt(0).toUpperCase() || 'A'}
|
||||||
<Badge variant="default">
|
</AvatarFallback>
|
||||||
<Shield className="h-3 w-3 mr-1" />
|
</Avatar>
|
||||||
Super Admin
|
<span className="font-medium truncate">
|
||||||
</Badge>
|
{admin.profile?.name || 'Unknown User'}
|
||||||
) : (
|
</span>
|
||||||
<div className="flex flex-wrap gap-1">
|
{isSelf && (
|
||||||
{admin.permissions.length === 0 ? (
|
<Badge variant="outline" className="ml-2">You</Badge>
|
||||||
<span className="text-muted-foreground text-sm">No permissions</span>
|
|
||||||
) : (
|
|
||||||
admin.permissions.slice(0, 3).map(perm => (
|
|
||||||
<Badge key={perm} variant="secondary" className="text-xs">
|
|
||||||
{PERMISSION_METADATA[perm as PermissionKey]?.label.replace('Can ', '') || perm}
|
|
||||||
</Badge>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
{admin.permissions.length > 3 && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
+{admin.permissions.length - 3} more
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground md:hidden">
|
||||||
</TableCell>
|
<span className="truncate max-w-[220px]">
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
{admin.profile?.email || '—'}
|
||||||
{admin.created_at ? format(new Date(admin.created_at), 'MMM d, yyyy') : '—'}
|
</span>
|
||||||
</TableCell>
|
{mobileRoleBadge}
|
||||||
<TableCell>
|
<span className="text-muted-foreground">
|
||||||
<div className="flex items-center gap-1">
|
Added {admin.created_at ? format(new Date(admin.created_at), 'MMM d, yyyy') : '—'}
|
||||||
{admin.user_id !== currentUser?.id && (
|
</span>
|
||||||
<>
|
</div>
|
||||||
|
{!isSelf && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2 md:hidden">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="icon"
|
size="sm"
|
||||||
onClick={() => handleEditPermissions(admin)}
|
onClick={() => handleEditPermissions(admin)}
|
||||||
title="Edit Permissions"
|
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-3 w-3 mr-1" />
|
||||||
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="icon"
|
size="sm"
|
||||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
onClick={() => handleRemoveAdmin(admin)}
|
onClick={() => handleRemoveAdmin(admin)}
|
||||||
title="Revoke Access"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-3 w-3 mr-1" />
|
||||||
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="hidden md:table-cell text-muted-foreground">
|
||||||
</TableRow>
|
{admin.profile?.email || '—'}
|
||||||
))
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">
|
||||||
|
{admin.role === 'super_admin' ? (
|
||||||
|
<Badge variant="default">
|
||||||
|
<Shield className="h-3 w-3 mr-1" />
|
||||||
|
Super Admin
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{admin.permissions.length === 0 ? (
|
||||||
|
<span className="text-muted-foreground text-sm">No permissions</span>
|
||||||
|
) : (
|
||||||
|
admin.permissions.slice(0, 3).map(perm => (
|
||||||
|
<Badge key={perm} variant="secondary" className="text-xs">
|
||||||
|
{PERMISSION_METADATA[perm as PermissionKey]?.label.replace('Can ', '') || perm}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{admin.permissions.length > 3 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{admin.permissions.length - 3} more
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden xl:table-cell text-muted-foreground text-sm">
|
||||||
|
{admin.created_at ? format(new Date(admin.created_at), 'MMM d, yyyy') : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{!isSelf && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleEditPermissions(admin)}
|
||||||
|
title="Edit Permissions"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleRemoveAdmin(admin)}
|
||||||
|
title="Revoke Access"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, Flame, Heart, ChevronUp, ChevronDown } from 'lucide-react';
|
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -52,9 +52,6 @@ interface DarePack {
|
|||||||
is_explicit: boolean;
|
is_explicit: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
category_id: string | null;
|
category_id: string | null;
|
||||||
min_intensity: number | null;
|
|
||||||
max_intensity: number | null;
|
|
||||||
avg_intensity: number | null;
|
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
dare_count?: number;
|
dare_count?: number;
|
||||||
}
|
}
|
||||||
@@ -296,15 +293,6 @@ export function DarePacksPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getIntensityColor = (avg: number | null) => {
|
|
||||||
if (avg === null) return 'bg-muted';
|
|
||||||
if (avg <= 1.5) return 'bg-green-500';
|
|
||||||
if (avg <= 2.5) return 'bg-lime-500';
|
|
||||||
if (avg <= 3.5) return 'bg-yellow-500';
|
|
||||||
if (avg <= 4.5) return 'bg-orange-500';
|
|
||||||
return 'bg-red-500';
|
|
||||||
};
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Render
|
// Render
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -446,9 +434,9 @@ export function DarePacksPage() {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label>Explicit</Label>
|
<Label>NSFW</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Contains adult/explicit content
|
Contains adult or mature content
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -456,6 +444,7 @@ export function DarePacksPage() {
|
|||||||
onCheckedChange={(checked) => form.setField('is_explicit', checked)}
|
onCheckedChange={(checked) => form.setField('is_explicit', checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -522,6 +511,11 @@ export function DarePacksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{pack.is_explicit && (
|
||||||
|
<Badge variant="destructive">
|
||||||
|
NSFW
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{pack.is_premium && (
|
{pack.is_premium && (
|
||||||
<Badge variant="default" className="bg-amber-500">
|
<Badge variant="default" className="bg-amber-500">
|
||||||
<Crown className="h-3 w-3 mr-1" />
|
<Crown className="h-3 w-3 mr-1" />
|
||||||
@@ -539,17 +533,6 @@ export function DarePacksPage() {
|
|||||||
Draft
|
Draft
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{pack.is_explicit ? (
|
|
||||||
<Badge variant="outline" className="border-red-500 text-red-400">
|
|
||||||
<Flame className="h-3 w-3 mr-1" />
|
|
||||||
Explicit
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" className="border-pink-400 text-pink-300">
|
|
||||||
<Heart className="h-3 w-3 mr-1" />
|
|
||||||
Safe
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
@@ -574,17 +557,6 @@ export function DarePacksPage() {
|
|||||||
<CardDescription className="line-clamp-2">
|
<CardDescription className="line-clamp-2">
|
||||||
{pack.description || 'No description'}
|
{pack.description || 'No description'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
{/* Intensity indicator */}
|
|
||||||
{pack.avg_intensity !== null && (
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Badge className={`${getIntensityColor(pack.avg_intensity)} text-white`}>
|
|
||||||
Avg: {pack.avg_intensity?.toFixed(1)}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Range: {pack.min_intensity ?? '?'} - {pack.max_intensity ?? '?'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ interface DarePack {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
is_explicit: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Dare {
|
interface Dare {
|
||||||
@@ -70,8 +69,6 @@ const DURATION_OPTIONS = [
|
|||||||
{ value: '168', label: '1 week' },
|
{ value: '168', label: '1 week' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const getDefaultIntensity = (isExplicit?: boolean) => (isExplicit ? 4 : 2);
|
|
||||||
|
|
||||||
// Format duration for display
|
// Format duration for display
|
||||||
const formatDuration = (hours: number | null): string => {
|
const formatDuration = (hours: number | null): string => {
|
||||||
if (hours === null) return '—';
|
if (hours === null) return '—';
|
||||||
@@ -101,11 +98,9 @@ export function DaresPage() {
|
|||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState<{
|
const [formData, setFormData] = useState<{
|
||||||
text: string;
|
text: string;
|
||||||
intensity: number;
|
|
||||||
suggested_duration_hours: string;
|
suggested_duration_hours: string;
|
||||||
}>({
|
}>({
|
||||||
text: '',
|
text: '',
|
||||||
intensity: getDefaultIntensity(),
|
|
||||||
suggested_duration_hours: '',
|
suggested_duration_hours: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,7 +112,7 @@ export function DaresPage() {
|
|||||||
// Fetch pack info
|
// Fetch pack info
|
||||||
const { data: packData } = await supabase
|
const { data: packData } = await supabase
|
||||||
.from('dare_packs')
|
.from('dare_packs')
|
||||||
.select('id, name, description, icon, is_explicit')
|
.select('id, name, description, icon')
|
||||||
.eq('id', packId)
|
.eq('id', packId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@@ -167,7 +162,7 @@ export function DaresPage() {
|
|||||||
|
|
||||||
const openCreateDialog = () => {
|
const openCreateDialog = () => {
|
||||||
setEditingDare(null);
|
setEditingDare(null);
|
||||||
setFormData({ text: '', intensity: getDefaultIntensity(pack?.is_explicit), suggested_duration_hours: '' });
|
setFormData({ text: '', suggested_duration_hours: '' });
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,7 +170,6 @@ export function DaresPage() {
|
|||||||
setEditingDare(dare);
|
setEditingDare(dare);
|
||||||
setFormData({
|
setFormData({
|
||||||
text: dare.text,
|
text: dare.text,
|
||||||
intensity: dare.intensity,
|
|
||||||
suggested_duration_hours: dare.suggested_duration_hours?.toString() || '',
|
suggested_duration_hours: dare.suggested_duration_hours?.toString() || '',
|
||||||
});
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
@@ -191,7 +185,6 @@ export function DaresPage() {
|
|||||||
try {
|
try {
|
||||||
const dareData = {
|
const dareData = {
|
||||||
text: formData.text,
|
text: formData.text,
|
||||||
intensity: formData.intensity,
|
|
||||||
suggested_duration_hours: formData.suggested_duration_hours
|
suggested_duration_hours: formData.suggested_duration_hours
|
||||||
? parseInt(formData.suggested_duration_hours, 10)
|
? parseInt(formData.suggested_duration_hours, 10)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { PaginationControls } from '@/components/ui/pagination';
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown, Clock, BellOff } from 'lucide-react';
|
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Tags, ChevronUp, ChevronDown, Clock, BellOff } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||||
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
|
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
|
||||||
@@ -513,6 +513,11 @@ export function PacksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{pack.is_explicit && (
|
||||||
|
<Badge variant="destructive">
|
||||||
|
NSFW
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{pack.is_premium && (
|
{pack.is_premium && (
|
||||||
<Badge variant="default" className="bg-amber-500">
|
<Badge variant="default" className="bg-amber-500">
|
||||||
<Crown className="h-3 w-3 mr-1" />
|
<Crown className="h-3 w-3 mr-1" />
|
||||||
@@ -542,17 +547,6 @@ export function PacksPage() {
|
|||||||
Draft
|
Draft
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{pack.is_explicit ? (
|
|
||||||
<Badge variant="outline" className="border-red-500 text-red-400">
|
|
||||||
<Flame className="h-3 w-3 mr-1" />
|
|
||||||
Explicit
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" className="border-pink-400 text-pink-300">
|
|
||||||
<Heart className="h-3 w-3 mr-1" />
|
|
||||||
Safe
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
|
|||||||
@@ -62,9 +62,6 @@ interface ChartDatum {
|
|||||||
|
|
||||||
type SegmentKey = 'all' | 'premium' | 'free';
|
type SegmentKey = 'all' | 'premium' | 'free';
|
||||||
|
|
||||||
const intensityPalette = ['#22c55e', '#84cc16', '#facc15', '#f97316', '#ef4444'];
|
|
||||||
const intensityLabels = ['Gentle', 'Warm', 'Playful', 'Steamy', 'Intense'];
|
|
||||||
|
|
||||||
const QUESTION_TYPE_CONFIG: Record<QuestionType, { label: string; color: string }> = {
|
const QUESTION_TYPE_CONFIG: Record<QuestionType, { label: string; color: string }> = {
|
||||||
swipe: { label: 'Swipe', color: '#38bdf8' },
|
swipe: { label: 'Swipe', color: '#38bdf8' },
|
||||||
text_answer: { label: 'Text', color: '#f59e0b' },
|
text_answer: { label: 'Text', color: '#f59e0b' },
|
||||||
@@ -101,11 +98,6 @@ export function QuestionAnalyticsPage() {
|
|||||||
});
|
});
|
||||||
const [premiumBreakdown, setPremiumBreakdown] = useState<ChartDatum[]>([]);
|
const [premiumBreakdown, setPremiumBreakdown] = useState<ChartDatum[]>([]);
|
||||||
const [questionTypeBreakdown, setQuestionTypeBreakdown] = useState<ChartDatum[]>([]);
|
const [questionTypeBreakdown, setQuestionTypeBreakdown] = useState<ChartDatum[]>([]);
|
||||||
const [intensityBreakdown, setIntensityBreakdown] = useState<Record<SegmentKey, ChartDatum[]>>({
|
|
||||||
all: [],
|
|
||||||
premium: [],
|
|
||||||
free: [],
|
|
||||||
});
|
|
||||||
const [topicBreakdown, setTopicBreakdown] = useState<Record<SegmentKey, BreakdownItem[]>>({
|
const [topicBreakdown, setTopicBreakdown] = useState<Record<SegmentKey, BreakdownItem[]>>({
|
||||||
all: [],
|
all: [],
|
||||||
premium: [],
|
premium: [],
|
||||||
@@ -121,7 +113,6 @@ export function QuestionAnalyticsPage() {
|
|||||||
premium: [],
|
premium: [],
|
||||||
free: [],
|
free: [],
|
||||||
});
|
});
|
||||||
const [intensitySegment, setIntensitySegment] = useState<SegmentKey>('all');
|
|
||||||
const [tagSegment, setTagSegment] = useState<SegmentKey>('all');
|
const [tagSegment, setTagSegment] = useState<SegmentKey>('all');
|
||||||
const [categorySegment, setCategorySegment] = useState<SegmentKey>('all');
|
const [categorySegment, setCategorySegment] = useState<SegmentKey>('all');
|
||||||
const [packSegment, setPackSegment] = useState<SegmentKey>('all');
|
const [packSegment, setPackSegment] = useState<SegmentKey>('all');
|
||||||
@@ -215,12 +206,6 @@ export function QuestionAnalyticsPage() {
|
|||||||
packTopicsMap.set(relation.pack_id, topicsForPack);
|
packTopicsMap.set(relation.pack_id, topicsForPack);
|
||||||
});
|
});
|
||||||
|
|
||||||
const emptyIntensityCounts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
|
||||||
const intensityCounts: Record<SegmentKey, Record<number, number>> = {
|
|
||||||
all: { ...emptyIntensityCounts },
|
|
||||||
premium: { ...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> = {
|
const questionTypeCounts: Record<QuestionType, number> = {
|
||||||
swipe: 0,
|
swipe: 0,
|
||||||
@@ -262,10 +247,6 @@ export function QuestionAnalyticsPage() {
|
|||||||
freeCount += 1;
|
freeCount += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intensity = question.intensity ?? 1;
|
|
||||||
intensityCounts.all[intensity] = (intensityCounts.all[intensity] || 0) + 1;
|
|
||||||
intensityCounts[segmentKey][intensity] = (intensityCounts[segmentKey][intensity] || 0) + 1;
|
|
||||||
|
|
||||||
const updatePackCounts = (key: SegmentKey) => {
|
const updatePackCounts = (key: SegmentKey) => {
|
||||||
const map = packCountsBySegment[key];
|
const map = packCountsBySegment[key];
|
||||||
const packEntry = map.get(question.pack_id) || {
|
const packEntry = map.get(question.pack_id) || {
|
||||||
@@ -332,12 +313,6 @@ export function QuestionAnalyticsPage() {
|
|||||||
color: config.color,
|
color: config.color,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const buildIntensityData = (key: SegmentKey) => intensityLabels.map((label, index) => ({
|
|
||||||
name: label,
|
|
||||||
value: intensityCounts[key][index + 1] || 0,
|
|
||||||
color: intensityPalette[index],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const buildTopicData = (key: SegmentKey) => {
|
const buildTopicData = (key: SegmentKey) => {
|
||||||
const data = Array.from(topicCountsBySegment[key].values());
|
const data = Array.from(topicCountsBySegment[key].values());
|
||||||
if (untaggedCounts[key] > 0 || data.length === 0) {
|
if (untaggedCounts[key] > 0 || data.length === 0) {
|
||||||
@@ -348,12 +323,6 @@ export function QuestionAnalyticsPage() {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const intensityData = {
|
|
||||||
all: buildIntensityData('all'),
|
|
||||||
premium: buildIntensityData('premium'),
|
|
||||||
free: buildIntensityData('free'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const topicData = {
|
const topicData = {
|
||||||
all: buildTopicData('all'),
|
all: buildTopicData('all'),
|
||||||
premium: buildTopicData('premium'),
|
premium: buildTopicData('premium'),
|
||||||
@@ -385,7 +354,6 @@ export function QuestionAnalyticsPage() {
|
|||||||
});
|
});
|
||||||
setPremiumBreakdown(premiumData);
|
setPremiumBreakdown(premiumData);
|
||||||
setQuestionTypeBreakdown(questionTypeData);
|
setQuestionTypeBreakdown(questionTypeData);
|
||||||
setIntensityBreakdown(intensityData);
|
|
||||||
setTopicBreakdown(topicData);
|
setTopicBreakdown(topicData);
|
||||||
setCategoryBreakdown(categoryData);
|
setCategoryBreakdown(categoryData);
|
||||||
setPackBreakdown(packData);
|
setPackBreakdown(packData);
|
||||||
@@ -427,7 +395,6 @@ export function QuestionAnalyticsPage() {
|
|||||||
free: stats.totalQuestions ? ((stats.freeQuestions / stats.totalQuestions) * 100).toFixed(0) : '0',
|
free: stats.totalQuestions ? ((stats.freeQuestions / stats.totalQuestions) * 100).toFixed(0) : '0',
|
||||||
};
|
};
|
||||||
|
|
||||||
const intensityData = intensityBreakdown[intensitySegment];
|
|
||||||
const tagData = topicBreakdown[tagSegment];
|
const tagData = topicBreakdown[tagSegment];
|
||||||
const categoryData = categoryBreakdown[categorySegment];
|
const categoryData = categoryBreakdown[categorySegment];
|
||||||
const packData = packBreakdown[packSegment];
|
const packData = packBreakdown[packSegment];
|
||||||
@@ -447,7 +414,7 @@ export function QuestionAnalyticsPage() {
|
|||||||
Question Analytics
|
Question Analytics
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Deep dive into premium coverage, tags, categories, and intensity distribution
|
Deep dive into premium coverage, tags, categories, and question types
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -601,78 +568,6 @@ export function QuestionAnalyticsPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Intensity Distribution</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
How questions are spread across intensity levels
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{segmentOptions.map((option) => (
|
|
||||||
<Button
|
|
||||||
key={option.key}
|
|
||||||
variant={intensitySegment === option.key ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIntensitySegment(option.key)}
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{segmentLabelByKey[intensitySegment]}: {totalBySegment[intensitySegment].toLocaleString()} questions ({segmentShareByKey[intensitySegment]}%)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
|
||||||
<Skeleton className="h-[300px] w-full" />
|
|
||||||
) : totalBySegment[intensitySegment] === 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
|
||||||
No questions in this segment yet
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-[300px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={intensityData}>
|
|
||||||
<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]}>
|
|
||||||
{intensityData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ interface QuestionPack {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
is_explicit: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
|
type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
|
||||||
@@ -74,7 +73,6 @@ interface Question {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_AUDIO_DURATION = 60;
|
const DEFAULT_AUDIO_DURATION = 60;
|
||||||
const getDefaultIntensity = (isExplicit?: boolean) => (isExplicit ? 4 : 2);
|
|
||||||
|
|
||||||
const QUESTION_TYPE_OPTIONS: Array<{ value: QuestionType; label: string }> = [
|
const QUESTION_TYPE_OPTIONS: Array<{ value: QuestionType; label: string }> = [
|
||||||
{ value: 'swipe', label: 'Swipe' },
|
{ value: 'swipe', label: 'Swipe' },
|
||||||
@@ -124,7 +122,6 @@ export function QuestionsPage() {
|
|||||||
const [formData, setFormData] = useState<{
|
const [formData, setFormData] = useState<{
|
||||||
text: string;
|
text: string;
|
||||||
partner_text: string;
|
partner_text: string;
|
||||||
intensity: number;
|
|
||||||
allowed_couple_genders: string[];
|
allowed_couple_genders: string[];
|
||||||
target_user_genders: string[];
|
target_user_genders: string[];
|
||||||
required_props: string;
|
required_props: string;
|
||||||
@@ -134,7 +131,6 @@ export function QuestionsPage() {
|
|||||||
}>({
|
}>({
|
||||||
text: '',
|
text: '',
|
||||||
partner_text: '',
|
partner_text: '',
|
||||||
intensity: getDefaultIntensity(),
|
|
||||||
allowed_couple_genders: [],
|
allowed_couple_genders: [],
|
||||||
target_user_genders: [],
|
target_user_genders: [],
|
||||||
required_props: '',
|
required_props: '',
|
||||||
@@ -156,7 +152,7 @@ export function QuestionsPage() {
|
|||||||
|
|
||||||
const packPromise = supabase
|
const packPromise = supabase
|
||||||
.from('question_packs')
|
.from('question_packs')
|
||||||
.select('id, name, description, icon, is_explicit')
|
.select('id, name, description, icon')
|
||||||
.eq('id', packId)
|
.eq('id', packId)
|
||||||
.single();
|
.single();
|
||||||
let questionsQuery = supabase
|
let questionsQuery = supabase
|
||||||
@@ -272,7 +268,6 @@ export function QuestionsPage() {
|
|||||||
setFormData({
|
setFormData({
|
||||||
text: '',
|
text: '',
|
||||||
partner_text: '',
|
partner_text: '',
|
||||||
intensity: getDefaultIntensity(pack?.is_explicit),
|
|
||||||
allowed_couple_genders: [],
|
allowed_couple_genders: [],
|
||||||
target_user_genders: [],
|
target_user_genders: [],
|
||||||
required_props: '',
|
required_props: '',
|
||||||
@@ -289,7 +284,6 @@ export function QuestionsPage() {
|
|||||||
setFormData({
|
setFormData({
|
||||||
text: question.text,
|
text: question.text,
|
||||||
partner_text: question.partner_text || '',
|
partner_text: question.partner_text || '',
|
||||||
intensity: question.intensity,
|
|
||||||
allowed_couple_genders: question.allowed_couple_genders || [],
|
allowed_couple_genders: question.allowed_couple_genders || [],
|
||||||
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(', ') ?? '',
|
||||||
@@ -326,7 +320,6 @@ export function QuestionsPage() {
|
|||||||
const { error } = await auditedSupabase.update('questions', editingQuestion.id, {
|
const { error } = await auditedSupabase.update('questions', editingQuestion.id, {
|
||||||
text: formData.text,
|
text: formData.text,
|
||||||
partner_text: formData.partner_text || null,
|
partner_text: formData.partner_text || null,
|
||||||
intensity: formData.intensity,
|
|
||||||
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
|
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
|
||||||
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,
|
||||||
@@ -342,7 +335,6 @@ export function QuestionsPage() {
|
|||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
text: formData.text,
|
text: formData.text,
|
||||||
partner_text: formData.partner_text || null,
|
partner_text: formData.partner_text || null,
|
||||||
intensity: formData.intensity,
|
|
||||||
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
|
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
|
||||||
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,
|
||||||
@@ -384,7 +376,6 @@ 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';
|
const generatedQuestionType: QuestionType = 'swipe';
|
||||||
const defaultIntensity = getDefaultIntensity(pack?.is_explicit);
|
|
||||||
|
|
||||||
// Bulk insert AI-generated questions with inverse_of linking
|
// Bulk insert AI-generated questions with inverse_of linking
|
||||||
const insertQuestions = async () => {
|
const insertQuestions = async () => {
|
||||||
@@ -411,7 +402,6 @@ export function QuestionsPage() {
|
|||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
text: q.text,
|
text: q.text,
|
||||||
partner_text: q.partner_text || null,
|
partner_text: q.partner_text || null,
|
||||||
intensity: defaultIntensity,
|
|
||||||
inverse_of: null,
|
inverse_of: null,
|
||||||
question_type: generatedQuestionType,
|
question_type: generatedQuestionType,
|
||||||
config: null,
|
config: null,
|
||||||
@@ -431,7 +421,6 @@ export function QuestionsPage() {
|
|||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
text: primary.text,
|
text: primary.text,
|
||||||
partner_text: primary.partner_text || null,
|
partner_text: primary.partner_text || null,
|
||||||
intensity: defaultIntensity,
|
|
||||||
inverse_of: null,
|
inverse_of: null,
|
||||||
question_type: generatedQuestionType,
|
question_type: generatedQuestionType,
|
||||||
config: null,
|
config: null,
|
||||||
@@ -449,7 +438,6 @@ export function QuestionsPage() {
|
|||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
text: inverse.text,
|
text: inverse.text,
|
||||||
partner_text: inverse.partner_text || null,
|
partner_text: inverse.partner_text || null,
|
||||||
intensity: defaultIntensity,
|
|
||||||
inverse_of: primaryId,
|
inverse_of: primaryId,
|
||||||
question_type: generatedQuestionType,
|
question_type: generatedQuestionType,
|
||||||
config: null,
|
config: null,
|
||||||
@@ -467,7 +455,6 @@ export function QuestionsPage() {
|
|||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
text: extra.text,
|
text: extra.text,
|
||||||
partner_text: extra.partner_text || null,
|
partner_text: extra.partner_text || null,
|
||||||
intensity: defaultIntensity,
|
|
||||||
inverse_of: null,
|
inverse_of: null,
|
||||||
question_type: generatedQuestionType,
|
question_type: generatedQuestionType,
|
||||||
config: null,
|
config: null,
|
||||||
@@ -483,7 +470,6 @@ export function QuestionsPage() {
|
|||||||
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: defaultIntensity,
|
|
||||||
inverse_of: null,
|
inverse_of: null,
|
||||||
question_type: generatedQuestionType,
|
question_type: generatedQuestionType,
|
||||||
config: null,
|
config: null,
|
||||||
@@ -649,7 +635,6 @@ export function QuestionsPage() {
|
|||||||
<AIPolishButton
|
<AIPolishButton
|
||||||
text={formData.text}
|
text={formData.text}
|
||||||
type="question"
|
type="question"
|
||||||
explicit={pack?.is_explicit ?? false}
|
|
||||||
onPolished={(val) => setFormData(d => ({ ...d, text: val }))}
|
onPolished={(val) => setFormData(d => ({ ...d, text: val }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -713,7 +698,6 @@ export function QuestionsPage() {
|
|||||||
<AIPolishButton
|
<AIPolishButton
|
||||||
text={formData.partner_text}
|
text={formData.partner_text}
|
||||||
type="partner_text"
|
type="partner_text"
|
||||||
explicit={pack?.is_explicit ?? false}
|
|
||||||
onPolished={(val) => setFormData(d => ({ ...d, partner_text: val }))}
|
onPolished={(val) => setFormData(d => ({ ...d, partner_text: val }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -926,7 +910,7 @@ export function QuestionsPage() {
|
|||||||
open={aiDialogOpen}
|
open={aiDialogOpen}
|
||||||
onOpenChange={setAiDialogOpen}
|
onOpenChange={setAiDialogOpen}
|
||||||
type="questions"
|
type="questions"
|
||||||
context={{ packName: pack?.name, packDescription: pack?.description, isExplicit: pack?.is_explicit, existingQuestions: questions.map(q => q.text) }}
|
context={{ packName: pack?.name, packDescription: pack?.description, existingQuestions: questions.map(q => q.text) }}
|
||||||
onGenerated={handleAiGenerated}
|
onGenerated={handleAiGenerated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -934,7 +918,6 @@ export function QuestionsPage() {
|
|||||||
open={reviewOpen}
|
open={reviewOpen}
|
||||||
onOpenChange={setReviewOpen}
|
onOpenChange={setReviewOpen}
|
||||||
questions={reviewQuestions}
|
questions={reviewQuestions}
|
||||||
isExplicit={pack?.is_explicit ?? false}
|
|
||||||
onUpdated={fetchData}
|
onUpdated={fetchData}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Heart, Sparkles, Flame, Trophy, Calendar } from 'lucide-react';
|
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { IconPreview } from '@/components/ui/icon-picker';
|
import { IconPreview } from '@/components/ui/icon-picker';
|
||||||
|
|
||||||
@@ -46,8 +46,6 @@ type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
|||||||
|
|
||||||
// ... (interfaces and constants are the same)
|
// ... (interfaces and constants are the same)
|
||||||
|
|
||||||
type IntensityLevel = 1 | 2 | 3 | 4 | 5;
|
|
||||||
|
|
||||||
interface Profile {
|
interface Profile {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -58,9 +56,8 @@ interface Profile {
|
|||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
gender: Gender | null;
|
gender: Gender | null;
|
||||||
usage_reason: UsageReason | null;
|
usage_reason: UsageReason | null;
|
||||||
show_explicit_content: boolean | null;
|
hide_nsfw: boolean | null;
|
||||||
onboarding_completed: boolean | null;
|
onboarding_completed: boolean | null;
|
||||||
max_intensity: IntensityLevel | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
|
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
|
||||||
@@ -78,14 +75,6 @@ const genderLabels: Record<Gender, string> = {
|
|||||||
'prefer-not-to-say': 'Prefer not to say',
|
'prefer-not-to-say': 'Prefer not to say',
|
||||||
};
|
};
|
||||||
|
|
||||||
const intensityLabels: Record<IntensityLevel, { label: string; emoji: string }> = {
|
|
||||||
1: { label: 'Gentle', emoji: '💭' },
|
|
||||||
2: { label: 'Warm', emoji: '💕' },
|
|
||||||
3: { label: 'Playful', emoji: '😏' },
|
|
||||||
4: { label: 'Steamy', emoji: '🔥' },
|
|
||||||
5: { label: 'Intense', emoji: '🌶️' },
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Partner {
|
interface Partner {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -556,11 +545,10 @@ export function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div></CardContent></Card>
|
</div></CardContent></Card>
|
||||||
{/* Onboarding Details */}
|
{/* Onboarding Details */}
|
||||||
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
|
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
|
||||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
|
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
|
||||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-pink-100 dark:bg-pink-900"><Heart className="h-4 w-4 text-pink-600 dark:text-pink-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Comfort Zone</p><p className="font-medium">{profile.max_intensity ? `${intensityLabels[profile.max_intensity].emoji} ${intensityLabels[profile.max_intensity].label} (${profile.max_intensity})` : 'Not set'}</p></div></div>
|
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.hide_nsfw ? 'bg-gray-100 dark:bg-gray-800' : 'bg-amber-100 dark:bg-amber-900'}`}>{profile.hide_nsfw ? <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" /> : <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Hide NSFW</p><p className="font-medium">{profile.hide_nsfw === null ? 'Not set' : profile.hide_nsfw ? 'Yes' : 'No'}</p></div></div>
|
||||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>{profile.show_explicit_content ? <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" /> : <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Explicit Content</p><p className="font-medium">{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}</p></div></div>
|
|
||||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
|
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
|
||||||
</div></CardContent></Card>
|
</div></CardContent></Card>
|
||||||
{/* Streak Info */}
|
{/* Streak Info */}
|
||||||
@@ -674,17 +662,17 @@ export function UserDetailPage() {
|
|||||||
<CardTitle className="text-base">{ep.pack.name}</CardTitle>
|
<CardTitle className="text-base">{ep.pack.name}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
{ep.pack.is_explicit && (
|
||||||
|
<Badge variant="destructive" className="text-xs">
|
||||||
|
NSFW
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{ep.pack.is_premium && (
|
{ep.pack.is_premium && (
|
||||||
<Badge className="bg-amber-500 text-xs">
|
<Badge className="bg-amber-500 text-xs">
|
||||||
<Crown className="h-3 w-3 mr-1" />
|
<Crown className="h-3 w-3 mr-1" />
|
||||||
Premium
|
Premium
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{ep.pack.is_explicit && (
|
|
||||||
<Badge variant="destructive" className="text-xs">
|
|
||||||
18+
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{ep.pack.description && (
|
{ep.pack.description && (
|
||||||
|
|||||||
@@ -226,13 +226,13 @@ export function UsersPage() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>User</TableHead>
|
<TableHead>User</TableHead>
|
||||||
<TableHead>Email</TableHead>
|
<TableHead className="hidden md:table-cell">Email</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead className="hidden md:table-cell">Status</TableHead>
|
||||||
<TableHead>Couple</TableHead>
|
<TableHead className="hidden lg:table-cell">Couple</TableHead>
|
||||||
<TableHead>Storage</TableHead>
|
<TableHead className="hidden lg:table-cell">Storage</TableHead>
|
||||||
<TableHead>Joined</TableHead>
|
<TableHead className="hidden xl:table-cell">Joined</TableHead>
|
||||||
<TableHead>Last Active</TableHead>
|
<TableHead className="hidden xl:table-cell">Last Active</TableHead>
|
||||||
<TableHead className="w-12"></TableHead>
|
<TableHead className="hidden md:table-cell w-12"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -243,102 +243,137 @@ export function UsersPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
paginatedProfiles.map((profile) => (
|
paginatedProfiles.map((profile) => {
|
||||||
<TableRow key={profile.id}>
|
const statusBadge = profile.is_premium ? (
|
||||||
<TableCell>
|
<Badge className="bg-amber-500">
|
||||||
<Link to={`/users/${profile.id}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
<Crown className="h-3 w-3 mr-1" />
|
||||||
<Avatar className="h-9 w-9">
|
Premium
|
||||||
<AvatarImage src={profile.avatar_url || undefined} />
|
</Badge>
|
||||||
<AvatarFallback>
|
) : profile.partner_is_premium ? (
|
||||||
{profile.name?.charAt(0).toUpperCase() || 'U'}
|
<Badge className="bg-amber-500/70">
|
||||||
</AvatarFallback>
|
<Crown className="h-3 w-3 mr-1" />
|
||||||
</Avatar>
|
Partner
|
||||||
<span className="font-medium hover:text-primary transition-colors">
|
</Badge>
|
||||||
{profile.name || 'Unnamed User'}
|
) : (
|
||||||
</span>
|
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">
|
||||||
</Link>
|
Free
|
||||||
</TableCell>
|
</Badge>
|
||||||
<TableCell className="text-muted-foreground">
|
);
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{profile.email ? (
|
const coupleBadge = profile.couple_id ? (
|
||||||
<>
|
profile.partner_count && profile.partner_count >= 2 ? (
|
||||||
{profile.email}
|
<Badge variant="secondary" className="bg-green-100 text-green-700 hover:bg-green-200">
|
||||||
{!profile.email_confirmed_at && (
|
<Users className="h-3 w-3 mr-1" />
|
||||||
<span title="Email not verified">
|
Paired
|
||||||
<MailWarning className="h-3.5 w-3.5 text-amber-500" />
|
</Badge>
|
||||||
</span>
|
) : (
|
||||||
)}
|
<Badge variant="secondary" className="bg-amber-100 text-amber-700 hover:bg-amber-200">
|
||||||
</>
|
<Hourglass className="h-3 w-3 mr-1" />
|
||||||
) : (
|
Pairing
|
||||||
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">
|
</Badge>
|
||||||
Guest
|
)
|
||||||
</Badge>
|
) : (
|
||||||
)}
|
<span className="text-muted-foreground text-xs">Not paired</span>
|
||||||
</div>
|
);
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
const emailContent = profile.email ? (
|
||||||
{profile.is_premium ? (
|
<span className="flex items-center gap-1.5 truncate max-w-[220px]">
|
||||||
<Badge className="bg-amber-500">
|
<span className="truncate">{profile.email}</span>
|
||||||
<Crown className="h-3 w-3 mr-1" />
|
{!profile.email_confirmed_at && (
|
||||||
Premium
|
<MailWarning className="h-3.5 w-3.5 text-amber-500" aria-label="Email not verified" />
|
||||||
</Badge>
|
|
||||||
) : profile.partner_is_premium ? (
|
|
||||||
<Badge className="bg-amber-500/70">
|
|
||||||
<Crown className="h-3 w-3 mr-1" />
|
|
||||||
Partner
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">
|
|
||||||
Free
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</span>
|
||||||
<TableCell>
|
) : (
|
||||||
{profile.couple_id ? (
|
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">
|
||||||
profile.partner_count && profile.partner_count >= 2 ? (
|
Guest
|
||||||
<Badge variant="secondary" className="bg-green-100 text-green-700 hover:bg-green-200">
|
</Badge>
|
||||||
<Users className="h-3 w-3 mr-1" />
|
);
|
||||||
Paired
|
|
||||||
</Badge>
|
const storageLabel = profile.storage_bytes && profile.storage_bytes > 0
|
||||||
) : (
|
? formatBytes(profile.storage_bytes)
|
||||||
<Badge variant="secondary" className="bg-amber-100 text-amber-700 hover:bg-amber-200">
|
: '—';
|
||||||
<Hourglass className="h-3 w-3 mr-1" />
|
|
||||||
Pairing
|
const joinedLabel = profile.created_at
|
||||||
</Badge>
|
? format(new Date(profile.created_at), 'MMM d, yyyy')
|
||||||
)
|
: '—';
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground text-sm">—</span>
|
const lastActiveLabel = profile.last_sign_in_at
|
||||||
)}
|
? format(new Date(profile.last_sign_in_at), 'MMM d, yyyy h:mm a')
|
||||||
</TableCell>
|
: 'Never';
|
||||||
<TableCell>
|
|
||||||
{profile.storage_bytes && profile.storage_bytes > 0 ? (
|
return (
|
||||||
<div className="flex items-center gap-1 text-sm">
|
<TableRow key={profile.id}>
|
||||||
<HardDrive className="h-3 w-3 text-muted-foreground" />
|
<TableCell>
|
||||||
{formatBytes(profile.storage_bytes)}
|
<div className="flex items-center gap-3">
|
||||||
|
<Link to={`/users/${profile.id}`} className="flex items-center gap-3 min-w-0 hover:opacity-80 transition-opacity">
|
||||||
|
<Avatar className="h-9 w-9">
|
||||||
|
<AvatarImage src={profile.avatar_url || undefined} />
|
||||||
|
<AvatarFallback>
|
||||||
|
{profile.name?.charAt(0).toUpperCase() || 'U'}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="font-medium truncate hover:text-primary transition-colors">
|
||||||
|
{profile.name || 'Unnamed User'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<div className="ml-auto md:hidden">
|
||||||
|
<Link to={`/users/${profile.id}`}>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground md:hidden">
|
||||||
<span className="text-muted-foreground text-sm">—</span>
|
{emailContent}
|
||||||
)}
|
{statusBadge}
|
||||||
</TableCell>
|
{coupleBadge}
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
{storageLabel !== '—' && (
|
||||||
{profile.created_at
|
<span className="flex items-center gap-1">
|
||||||
? format(new Date(profile.created_at), 'MMM d, yyyy')
|
<HardDrive className="h-3 w-3 text-muted-foreground" />
|
||||||
: '—'}
|
{storageLabel}
|
||||||
</TableCell>
|
</span>
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
)}
|
||||||
{profile.last_sign_in_at
|
<span className="text-muted-foreground">Active {lastActiveLabel}</span>
|
||||||
? format(new Date(profile.last_sign_in_at), 'MMM d, yyyy h:mm a')
|
</div>
|
||||||
: 'Never'}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell className="hidden md:table-cell text-muted-foreground">
|
||||||
<TableCell>
|
<div className="flex items-center gap-1.5">
|
||||||
<Link to={`/users/${profile.id}`}>
|
{emailContent}
|
||||||
<Button variant="ghost" size="icon">
|
</div>
|
||||||
<ChevronRight className="h-4 w-4" />
|
</TableCell>
|
||||||
</Button>
|
<TableCell className="hidden md:table-cell">
|
||||||
</Link>
|
{statusBadge}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
<TableCell className="hidden lg:table-cell">
|
||||||
))
|
{profile.couple_id ? coupleBadge : <span className="text-muted-foreground text-sm">—</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">
|
||||||
|
{profile.storage_bytes && profile.storage_bytes > 0 ? (
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<HardDrive className="h-3 w-3 text-muted-foreground" />
|
||||||
|
{storageLabel}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden xl:table-cell text-muted-foreground text-sm">
|
||||||
|
{joinedLabel}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden xl:table-cell text-muted-foreground text-sm">
|
||||||
|
{lastActiveLabel}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
<Link to={`/users/${profile.id}`}>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# New Question Types Draft - Non-Explicit Packs
|
||||||
|
|
||||||
|
New questions using **audio**, **photo**, and **who_likely** types for all non-explicit question packs. Questions match the tone, intensity range, and theme of each pack.
|
||||||
|
|
||||||
|
**Question types:**
|
||||||
|
- **audio** - User records a voice response (max 60s)
|
||||||
|
- **photo** - User uploads/captures a photo
|
||||||
|
- **who_likely** - "Who is more likely to..." (each partner picks who)
|
||||||
|
|
||||||
|
None of these types use partner_text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Staying Close (Long Distance, intensity 1)
|
||||||
|
|
||||||
|
| # | Type | Text | Intensity |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1 | audio | Record a voice note saying good morning to your partner | 1 |
|
||||||
|
| 2 | audio | Record yourself humming or singing a song that reminds you of your partner | 1 |
|
||||||
|
| 3 | audio | Leave a voice note describing the best part of your day | 1 |
|
||||||
|
| 4 | audio | Record a bedtime voice note for your partner to fall asleep to | 1 |
|
||||||
|
| 5 | photo | Send a photo of your current view right now | 1 |
|
||||||
|
| 6 | photo | Share a photo of something that made you smile today | 1 |
|
||||||
|
| 7 | photo | Take a photo of your pillow and empty bed | 1 |
|
||||||
|
| 8 | photo | Send a photo of your morning coffee or tea | 1 |
|
||||||
|
| 9 | who_likely | Who is more likely to call the other first? | 1 |
|
||||||
|
| 10 | who_likely | Who is more likely to fall asleep on a video call? | 1 |
|
||||||
|
| 11 | who_likely | Who is more likely to send a random "I miss you" text? | 1 |
|
||||||
|
| 12 | who_likely | Who is more likely to plan a surprise visit? | 1 |
|
||||||
|
| 13 | who_likely | Who is more likely to cry at the airport? | 1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Finding Your Vibe (Social Life, intensity 1)
|
||||||
|
|
||||||
|
| # | Type | Text | Intensity |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1 | who_likely | Who is more likely to be the last one on the dance floor? | 1 |
|
||||||
|
| 2 | who_likely | Who is more likely to make friends with a stranger at a party? | 1 |
|
||||||
|
| 3 | who_likely | Who is more likely to suggest leaving early? | 1 |
|
||||||
|
| 4 | who_likely | Who is more likely to be the designated driver? | 1 |
|
||||||
|
| 5 | who_likely | Who is more likely to start a karaoke session? | 1 |
|
||||||
|
| 6 | who_likely | Who is more likely to cancel plans to stay in? | 1 |
|
||||||
|
| 7 | audio | Describe your perfect night out in a voice note | 1 |
|
||||||
|
| 8 | photo | Share a photo of your go-to going-out outfit | 1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Weekend Warriors (Adventure & Travel, intensity 1-2)
|
||||||
|
|
||||||
|
| # | Type | Text | Intensity |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1 | who_likely | Who is more likely to suggest an unplanned road trip? | 1 |
|
||||||
|
| 2 | who_likely | Who is more likely to get competitive at mini golf? | 1 |
|
||||||
|
| 3 | who_likely | Who is more likely to try a new restaurant without checking reviews? | 1 |
|
||||||
|
| 4 | who_likely | Who is more likely to spend the entire weekend on the couch? | 1 |
|
||||||
|
| 5 | who_likely | Who is more likely to drag the other to a flea market? | 1 |
|
||||||
|
| 6 | photo | Share a photo of a place nearby you've been wanting to visit together | 1 |
|
||||||
|
| 7 | photo | Take a photo of your ideal weekend morning setup | 1 |
|
||||||
|
| 8 | audio | Describe a weekend adventure you'd love to go on together | 1 |
|
||||||
|
| 9 | audio | Tell your partner about your favorite weekend memory together | 1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Outdoor Explorers (Adventure & Travel, intensity 1)
|
||||||
|
|
||||||
|
| # | Type | Text | Intensity |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1 | who_likely | Who is more likely to suggest a spontaneous hike? | 1 |
|
||||||
|
| 2 | who_likely | Who is more likely to get lost on a trail? | 1 |
|
||||||
|
| 3 | who_likely | Who is more likely to complain about the weather? | 1 |
|
||||||
|
| 4 | who_likely | Who is more likely to forget the sunscreen? | 1 |
|
||||||
|
| 5 | who_likely | Who would survive longer in the wilderness? | 1 |
|
||||||
|
| 6 | photo | Share a photo of your favorite outdoor spot | 1 |
|
||||||
|
| 7 | photo | Take a photo of the view from your window right now | 1 |
|
||||||
|
| 8 | audio | Describe your dream outdoor adventure in a voice note | 1 |
|
||||||
|
| 9 | audio | Tell your partner about a time in nature that felt magical | 1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Romantic Gestures (Romance & Sensuality, intensity 2-4)
|
||||||
|
|
||||||
|
| # | Type | Text | Intensity |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1 | audio | Whisper something sweet to your partner | 2 |
|
||||||
|
| 2 | audio | Record yourself saying what you love most about your partner | 2 |
|
||||||
|
| 3 | audio | Tell your partner about a moment with them you'll never forget | 2 |
|
||||||
|
| 4 | audio | In your most romantic voice, tell your partner what they mean to you | 3 |
|
||||||
|
| 5 | audio | Describe the first time you knew you were falling for your partner | 2 |
|
||||||
|
| 6 | photo | Set up a romantic scene at home and share a photo | 2 |
|
||||||
|
| 7 | photo | Write a short love note by hand and send a photo of it | 2 |
|
||||||
|
| 8 | photo | Share a photo from a moment together that makes your heart race | 2 |
|
||||||
|
| 9 | who_likely | Who is more likely to plan a surprise romantic gesture? | 2 |
|
||||||
|
| 10 | who_likely | Who is more likely to initiate a slow dance in the kitchen? | 2 |
|
||||||
|
| 11 | who_likely | Who is more likely to write a love letter? | 2 |
|
||||||
|
| 12 | who_likely | Who is more likely to pull the other in for a random kiss? | 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Sensual Massage (Sensual Discovery, intensity 2-3)
|
||||||
|
|
||||||
|
| # | Type | Text | Intensity |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1 | who_likely | Who is more likely to ask for a massage after a long day? | 2 |
|
||||||
|
| 2 | who_likely | Who is more likely to fall asleep during a massage? | 2 |
|
||||||
|
| 3 | who_likely | Who gives the better back massage? | 2 |
|
||||||
|
| 4 | audio | Describe how you would give your partner the perfect massage | 3 |
|
||||||
|
| 5 | audio | Tell your partner which part of your body holds the most tension | 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Missing You (Long Distance, intensity 1-3)
|
||||||
|
|
||||||
|
| # | Type | Text | Intensity |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1 | audio | Record a whispered "I miss you" voice note | 2 |
|
||||||
|
| 2 | audio | Describe what you'd do in the first five minutes of seeing your partner again | 2 |
|
||||||
|
| 3 | audio | Read your partner a short goodnight message in your sleepiest voice | 1 |
|
||||||
|
| 4 | audio | Tell your partner the thing you miss most about being together | 1 |
|
||||||
|
| 5 | photo | Take a selfie right now, no filter, no prep | 1 |
|
||||||
|
| 6 | photo | Share a photo of something you bought that reminded you of your partner | 1 |
|
||||||
|
| 7 | photo | Send a photo of what you're wearing right now | 2 |
|
||||||
|
| 8 | photo | Share a photo of your favorite thing your partner left at your place | 1 |
|
||||||
|
| 9 | who_likely | Who is more likely to send a 3am "thinking of you" text? | 1 |
|
||||||
|
| 10 | who_likely | Who is more likely to rewatch old photos and videos of you two? | 1 |
|
||||||
|
| 11 | who_likely | Who is more likely to wear the other person's hoodie? | 1 |
|
||||||
|
| 12 | who_likely | Who is more likely to get emotional hearing a song that reminds them of the other? | 1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Everyday Moments (Quality Time, intensity 1)
|
||||||
|
|
||||||
|
| # | Type | Text | Intensity |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1 | photo | Share a photo of your morning coffee or breakfast | 1 |
|
||||||
|
| 2 | photo | Take a photo of your workspace or desk setup | 1 |
|
||||||
|
| 3 | photo | Share a photo of something small that made your day better | 1 |
|
||||||
|
| 4 | audio | Describe your favorite daily ritual you share together | 1 |
|
||||||
|
| 5 | audio | Tell your partner what you were just thinking about | 1 |
|
||||||
|
| 6 | who_likely | Who is more likely to wake up first? | 1 |
|
||||||
|
| 7 | who_likely | Who is more likely to cook dinner on a weeknight? | 1 |
|
||||||
|
| 8 | who_likely | Who is more likely to fall asleep on the couch? | 1 |
|
||||||
|
| 9 | who_likely | Who is more likely to suggest ordering takeout? | 1 |
|
||||||
|
| 10 | who_likely | Who is more likely to do the dishes without being asked? | 1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Relationship Essentials (Quality Time, intensity 1)
|
||||||
|
|
||||||
|
| # | Type | Text | Intensity |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1 | audio | Tell your partner one thing you appreciate about them that you don't say enough | 1 |
|
||||||
|
| 2 | audio | Describe in your own words what makes your relationship work | 1 |
|
||||||
|
| 3 | audio | Tell your partner about a time they made you feel truly safe | 1 |
|
||||||
|
| 4 | who_likely | Who is more likely to apologize first after an argument? | 1 |
|
||||||
|
| 5 | who_likely | Who is more likely to bring up something that's bothering them? | 1 |
|
||||||
|
| 6 | who_likely | Who is more likely to remember an anniversary or special date? | 1 |
|
||||||
|
| 7 | who_likely | Who is more likely to compromise during a disagreement? | 1 |
|
||||||
|
| 8 | who_likely | Who is more likely to say "I love you" first each day? | 1 |
|
||||||
|
| 9 | photo | Share a photo of something that reminds you of your relationship | 1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Hosting (Social Life, intensity 1)
|
||||||
|
|
||||||
|
| # | Type | Text | Intensity |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1 | who_likely | Who is more likely to overdo it on the snacks and decorations? | 1 |
|
||||||
|
| 2 | who_likely | Who is more likely to still be cleaning five minutes before guests arrive? | 1 |
|
||||||
|
| 3 | who_likely | Who is the better DJ at a house party? | 1 |
|
||||||
|
| 4 | who_likely | Who is more likely to invite too many people? | 1 |
|
||||||
|
| 5 | photo | Share a photo of your dream hosting setup or table setting | 1 |
|
||||||
|
| 6 | audio | Describe your dream dinner party - who's invited and what's on the menu? | 1 |
|
||||||
|
| 7 | audio | What's your signature dish you'd make for guests? Describe it | 1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Body Mapping (Sensual Discovery, intensity 2-3)
|
||||||
|
|
||||||
|
| # | Type | Text | Intensity |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1 | who_likely | Who is more ticklish? | 2 |
|
||||||
|
| 2 | who_likely | Who has more sensitive ears? | 2 |
|
||||||
|
| 3 | who_likely | Who reacts more to having their neck kissed? | 2 |
|
||||||
|
| 4 | who_likely | Who is more likely to discover a new sensitive spot on the other? | 2 |
|
||||||
|
| 5 | audio | Describe where you most love being touched by your partner | 3 |
|
||||||
|
| 6 | audio | Tell your partner the kind of touch that instantly relaxes you | 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Date Night Ideas (Quality Time, intensity 1-2)
|
||||||
|
|
||||||
|
| # | Type | Text | Intensity |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1 | photo | Share a photo of a restaurant or cafe you'd love to take your partner to | 1 |
|
||||||
|
| 2 | photo | Take a photo of your date night outfit | 1 |
|
||||||
|
| 3 | photo | Share a photo of a dish you'd love to cook together | 1 |
|
||||||
|
| 4 | audio | Pitch your dream date night from start to finish | 1 |
|
||||||
|
| 5 | audio | Describe the best date you've ever been on together | 1 |
|
||||||
|
| 6 | who_likely | Who is more likely to pick the restaurant? | 1 |
|
||||||
|
| 7 | who_likely | Who is more likely to try something completely new on a date? | 1 |
|
||||||
|
| 8 | who_likely | Who is more likely to plan a surprise date? | 1 |
|
||||||
|
| 9 | who_likely | Who is more likely to lose track of time on a date because you're having too much fun? | 1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. First Impressions (Roleplay, intensity 2-3)
|
||||||
|
|
||||||
|
| # | Type | Text | Intensity |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1 | audio | Introduce yourself to your partner as if you're meeting for the first time | 2 |
|
||||||
|
| 2 | audio | Deliver your best pick-up line to your partner | 2 |
|
||||||
|
| 3 | audio | In character, leave a voicemail asking your partner on a first date | 2 |
|
||||||
|
| 4 | audio | Describe what you'd wear to impress your partner if you were meeting for the first time | 2 |
|
||||||
|
| 5 | who_likely | Who is more likely to approach the other if you were strangers? | 2 |
|
||||||
|
| 6 | who_likely | Who is more likely to break character and laugh? | 2 |
|
||||||
|
| 7 | who_likely | Who is more likely to use a cheesy pick-up line? | 2 |
|
||||||
|
| 8 | who_likely | Who would be more nervous on a real first date? | 2 |
|
||||||
|
| 9 | photo | Snap a photo of the outfit you'd wear to make a killer first impression | 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Food & Culture (Adventure & Travel, intensity 1)
|
||||||
|
|
||||||
|
| # | Type | Text | Intensity |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1 | photo | Share a photo of a dish you've been craving lately | 1 |
|
||||||
|
| 2 | photo | Take a photo of something in your kitchen that tells a story | 1 |
|
||||||
|
| 3 | audio | Describe a meal from your childhood that means a lot to you | 1 |
|
||||||
|
| 4 | audio | Tell your partner about a cuisine you've always wanted to try together | 1 |
|
||||||
|
| 5 | who_likely | Who is the pickier eater? | 1 |
|
||||||
|
| 6 | who_likely | Who is more likely to try an unusual local delicacy while traveling? | 1 |
|
||||||
|
| 7 | who_likely | Who is more likely to send back a dish at a restaurant? | 1 |
|
||||||
|
| 8 | who_likely | Who is the better cook? | 1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Pack | Audio | Photo | Who Likely | Total New |
|
||||||
|
|------|-------|-------|------------|-----------|
|
||||||
|
| Staying Close | 4 | 4 | 5 | **13** |
|
||||||
|
| Finding Your Vibe | 1 | 1 | 6 | **8** |
|
||||||
|
| Weekend Warriors | 2 | 2 | 5 | **9** |
|
||||||
|
| Outdoor Explorers | 2 | 2 | 5 | **9** |
|
||||||
|
| Romantic Gestures | 5 | 3 | 4 | **12** |
|
||||||
|
| Sensual Massage | 2 | 0 | 3 | **5** |
|
||||||
|
| Missing You | 4 | 4 | 4 | **12** |
|
||||||
|
| Everyday Moments | 2 | 3 | 5 | **10** |
|
||||||
|
| Relationship Essentials | 3 | 1 | 5 | **9** |
|
||||||
|
| Hosting | 2 | 1 | 4 | **7** |
|
||||||
|
| Body Mapping | 2 | 0 | 4 | **6** |
|
||||||
|
| Date Night Ideas | 2 | 3 | 4 | **9** |
|
||||||
|
| First Impressions | 4 | 1 | 4 | **9** |
|
||||||
|
| Food & Culture | 2 | 2 | 4 | **8** |
|
||||||
|
| **Totals** | **37** | **27** | **62** | **126** |
|
||||||
Reference in new issue
Block a user