Add question types, nudge feature, and share modal

- Add new question card components (photo, audio, text, who-likely)
- Add share preview modal for matches
- Add nudge notification system with rate limiting
- Add session-based notification improvements
- Add match confetti celebration animation
- Update onboarding flow with paywall tracking
- Remove deprecated swipe card and tutorial components
- Update GlassCard/GlassInput to use solid backgrounds
- Fix routing issues after packs screen removal
This commit is contained in:
jjbrunton committed 2026-01-25 19:17:39 +00:00
1 parent 8612b16b93
commit 1daf386765
114 files changed
+14877 -7911

No files matched your search

@@ -114,7 +114,7 @@ export function AiGeneratorDialog({
context={context}
config={generation.config}
onCountChange={generation.setCount}
onToneChange={generation.setTone}
onExplicitChange={generation.setExplicit}
onCrudeLangChange={generation.setCrudeLang}
onInspirationChange={generation.setInspiration}
/>
+11 -42
View File
@@ -1,9 +1,7 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { TONE_LEVELS, type ToneLevel } from '@/lib/openai';
import type { GeneratorType, GeneratorContext, GenerationConfig } from './hooks/useAiGeneration';
// =============================================================================
@@ -15,7 +13,7 @@ interface GenerationFormProps {
context?: GeneratorContext;
config: GenerationConfig;
onCountChange: (count: number) => void;
onToneChange: (tone: ToneLevel) => void;
onExplicitChange: (isExplicit: boolean) => void;
onCrudeLangChange: (crudeLang: boolean) => void;
onInspirationChange: (inspiration: string) => void;
}
@@ -29,12 +27,10 @@ export function GenerationForm({
context,
config,
onCountChange,
onToneChange,
onExplicitChange,
onCrudeLangChange,
onInspirationChange,
}: GenerationFormProps) {
const isExplicit = config.tone >= 4;
return (
<div className="space-y-4">
{/* Question Count (questions only) */}
@@ -53,42 +49,15 @@ export function GenerationForm({
</div>
)}
{/* Intimacy Level Selector (questions only) */}
{type === 'questions' && (
<div className="space-y-2">
<Label>Intimacy Level</Label>
<div className="flex flex-wrap gap-2">
{TONE_LEVELS.map((t) => (
<Button
key={t.level}
type="button"
variant={config.tone === t.level ? 'default' : 'outline'}
size="sm"
onClick={() => onToneChange(t.level as ToneLevel)}
className={config.tone === t.level ? 'ring-2 ring-offset-1' : ''}
title={t.description}
>
{t.level}. {t.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{TONE_LEVELS.find(t => t.level === config.tone)?.description}
</p>
</div>
)}
{/* Wild/Explicit Toggle (for other generators) */}
{(type === 'category-ideas' || type === 'category-pack-ideas' || type === 'pack') && (
<div className="flex items-center space-x-2">
<Switch
id="explicit-mode"
checked={isExplicit}
onCheckedChange={(checked: boolean) => onToneChange(checked ? 5 : 3)}
/>
<Label htmlFor="explicit-mode">Include Wild/Adult Ideas</Label>
</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">
+1 -12
View File
@@ -10,7 +10,6 @@ import {
Bot,
} from 'lucide-react';
import {
INTENSITY_LEVELS,
type GeneratedQuestion,
type QuestionReview,
type CouncilGenerationResult,
@@ -199,16 +198,6 @@ function QuestionItem({
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Intensity Badge */}
<span
className={`inline-block text-xs px-2 py-0.5 rounded text-white ${
INTENSITY_LEVELS[question.intensity - 1]?.color || 'bg-muted'
}`}
title={INTENSITY_LEVELS[question.intensity - 1]?.description}
>
{question.intensity} - {INTENSITY_LEVELS[question.intensity - 1]?.label || 'Unknown'}
</span>
{/* Source Generator Badge (cherry-pick mode) */}
{selectionMode === 'cherry_pick' && question.sourceGeneratorModel && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
@@ -228,7 +217,7 @@ function QuestionItem({
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`}
title={review.scores
? `Scores: ${review.scores.guidelineCompliance}/${review.scores.creativity}/${review.scores.clarity}/${review.scores.intensityAccuracy}`
? `Scores: ${review.scores.guidelineCompliance}/${review.scores.creativity}/${review.scores.clarity}`
: ''
}
>
@@ -91,15 +91,6 @@ export function ReviewSummary({
</p>
)}
{/* Intensity distribution for cherry-pick */}
{selectionMode === 'cherry_pick' && cherryPickResult && Object.keys(cherryPickResult.intensityDistribution).length > 0 && (
<p>
Intensity distribution: {Object.entries(cherryPickResult.intensityDistribution)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([level, count]) => `L${level}:${count}`)
.join(', ')}
</p>
)}
</div>
)}
</div>
@@ -10,7 +10,6 @@ import {
type GeneratedQuestion,
type GeneratedCategoryIdea,
type GeneratedPackIdea,
type ToneLevel,
type QuestionReview,
type CouncilGenerationResult,
type GenerationCandidate,
@@ -38,7 +37,7 @@ export interface GeneratorContext {
export interface GenerationConfig {
count: number;
tone: ToneLevel;
isExplicit: boolean;
crudeLang: boolean;
inspiration: string;
}
@@ -77,7 +76,7 @@ export function useAiGeneration(
// Config state
const [config, setConfig] = useState<GenerationConfig>({
count: 10,
tone: context?.isExplicit ? 5 : 2,
isExplicit: Boolean(context?.isExplicit),
crudeLang: false,
inspiration: '',
});
@@ -118,14 +117,12 @@ export function useAiGeneration(
// Council config
const councilConfig = getCouncilConfig();
// Update tone when context changes
// Update explicit flag when context changes
useEffect(() => {
if (type === 'questions') {
setConfig(prev => ({
...prev,
tone: context?.isExplicit ? 5 : 2,
}));
}
setConfig(prev => ({
...prev,
isExplicit: Boolean(context?.isExplicit),
}));
}, [context?.isExplicit, type]);
// Config setters
@@ -133,8 +130,8 @@ export function useAiGeneration(
setConfig(prev => ({ ...prev, count }));
}, []);
const setTone = useCallback((tone: ToneLevel) => {
setConfig(prev => ({ ...prev, tone }));
const setExplicit = useCallback((isExplicit: boolean) => {
setConfig(prev => ({ ...prev, isExplicit }));
}, []);
const setCrudeLang = useCallback((crudeLang: boolean) => {
@@ -219,7 +216,7 @@ export function useAiGeneration(
setFilter('all');
setSourceModel(null);
const isExplicit = config.tone >= 4;
const isExplicit = config.isExplicit;
try {
if (type === 'pack') {
@@ -253,7 +250,7 @@ export function useAiGeneration(
context.packName,
config.count,
undefined,
config.tone,
isExplicit ? 5 : 2,
context.packDescription || undefined,
context.existingQuestions,
config.crudeLang,
@@ -369,7 +366,7 @@ export function useAiGeneration(
// Config
config,
setCount,
setTone,
setExplicit,
setCrudeLang,
setInspiration,
@@ -203,11 +203,12 @@ export function PackFormDialog({
<Switch
checked={formData.is_public}
onCheckedChange={(checked) => {
setField('is_public', checked);
// Clear scheduled release if making public immediately
if (checked) {
setField('scheduled_release_at', null);
}
const next = {
...formData,
is_public: checked,
...(checked ? { scheduled_release_at: null } : {}),
};
onFormChange(next);
}}
/>
</div>
+3 -33
View File
@@ -37,7 +37,6 @@ import { Plus, Pencil, Trash2, Zap, Loader2, Clock } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import { IconPreview } from '@/components/ui/icon-picker';
import { INTENSITY_LEVELS } from '@/lib/openai';
// =============================================================================
// Types
@@ -71,8 +70,7 @@ const DURATION_OPTIONS = [
{ value: '168', label: '1 week' },
];
// Helper to get intensity info by level (1-indexed)
const getIntensity = (level: number) => INTENSITY_LEVELS[level - 1] || INTENSITY_LEVELS[0];
const getDefaultIntensity = (isExplicit?: boolean) => (isExplicit ? 4 : 2);
// Format duration for display
const formatDuration = (hours: number | null): string => {
@@ -107,7 +105,7 @@ export function DaresPage() {
suggested_duration_hours: string;
}>({
text: '',
intensity: 1,
intensity: getDefaultIntensity(),
suggested_duration_hours: '',
});
@@ -169,7 +167,7 @@ export function DaresPage() {
const openCreateDialog = () => {
setEditingDare(null);
setFormData({ text: '', intensity: 1, suggested_duration_hours: '' });
setFormData({ text: '', intensity: getDefaultIntensity(pack?.is_explicit), suggested_duration_hours: '' });
setDialogOpen(true);
};
@@ -343,25 +341,6 @@ export function DaresPage() {
/>
</div>
<div className="space-y-2">
<Label>Intensity Level</Label>
<div className="flex gap-2 flex-wrap">
{INTENSITY_LEVELS.map((intensity) => (
<Button
key={intensity.level}
type="button"
variant={formData.intensity === intensity.level ? 'default' : 'outline'}
size="sm"
onClick={() => setFormData(d => ({ ...d, intensity: intensity.level }))}
className={formData.intensity === intensity.level ? intensity.color : ''}
title={intensity.description}
>
{intensity.level} - {intensity.label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label>Suggested Duration</Label>
<Select
@@ -467,7 +446,6 @@ export function DaresPage() {
</TableHead>
<TableHead className="w-12">#</TableHead>
<TableHead>Dare</TableHead>
<TableHead className="w-24">Intensity</TableHead>
<TableHead className="w-24">Duration</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
@@ -491,14 +469,6 @@ export function DaresPage() {
<TableCell>
<span className="line-clamp-2">{dare.text}</span>
</TableCell>
<TableCell>
<Badge
className={`${getIntensity(dare.intensity).color} text-white`}
title={getIntensity(dare.intensity).description}
>
{getIntensity(dare.intensity).label}
</Badge>
</TableCell>
<TableCell>
{dare.suggested_duration_hours ? (
<Badge variant="outline" className="text-xs">
+9 -40
View File
@@ -73,12 +73,8 @@ interface Question {
config?: QuestionConfig | null;
}
import { INTENSITY_LEVELS } from '@/lib/openai';
// Helper to get intensity info by level (1-indexed)
const getIntensity = (level: number) => INTENSITY_LEVELS[level - 1] || INTENSITY_LEVELS[0];
const DEFAULT_AUDIO_DURATION = 60;
const getDefaultIntensity = (isExplicit?: boolean) => (isExplicit ? 4 : 2);
const QUESTION_TYPE_OPTIONS: Array<{ value: QuestionType; label: string }> = [
{ value: 'swipe', label: 'Swipe' },
@@ -138,7 +134,7 @@ export function QuestionsPage() {
}>({
text: '',
partner_text: '',
intensity: 1,
intensity: getDefaultIntensity(),
allowed_couple_genders: [],
target_user_genders: [],
required_props: '',
@@ -276,7 +272,7 @@ export function QuestionsPage() {
setFormData({
text: '',
partner_text: '',
intensity: 1,
intensity: getDefaultIntensity(pack?.is_explicit),
allowed_couple_genders: [],
target_user_genders: [],
required_props: '',
@@ -388,6 +384,7 @@ export function QuestionsPage() {
const handleAiGenerated = (generatedQuestions: Array<{ text: string; partner_text?: string; intensity: number; inverse_pair_id?: string | null }>) => {
const generatedQuestionType: QuestionType = 'swipe';
const defaultIntensity = getDefaultIntensity(pack?.is_explicit);
// Bulk insert AI-generated questions with inverse_of linking
const insertQuestions = async () => {
@@ -414,7 +411,7 @@ export function QuestionsPage() {
pack_id: packId,
text: q.text,
partner_text: q.partner_text || null,
intensity: q.intensity,
intensity: defaultIntensity,
inverse_of: null,
question_type: generatedQuestionType,
config: null,
@@ -434,7 +431,7 @@ export function QuestionsPage() {
pack_id: packId,
text: primary.text,
partner_text: primary.partner_text || null,
intensity: primary.intensity,
intensity: defaultIntensity,
inverse_of: null,
question_type: generatedQuestionType,
config: null,
@@ -452,7 +449,7 @@ export function QuestionsPage() {
pack_id: packId,
text: inverse.text,
partner_text: inverse.partner_text || null,
intensity: inverse.intensity,
intensity: defaultIntensity,
inverse_of: primaryId,
question_type: generatedQuestionType,
config: null,
@@ -470,7 +467,7 @@ export function QuestionsPage() {
pack_id: packId,
text: extra.text,
partner_text: extra.partner_text || null,
intensity: extra.intensity,
intensity: defaultIntensity,
inverse_of: null,
question_type: generatedQuestionType,
config: null,
@@ -486,7 +483,7 @@ export function QuestionsPage() {
pack_id: packId,
text: pair[0].text,
partner_text: pair[0].partner_text || null,
intensity: pair[0].intensity,
intensity: defaultIntensity,
inverse_of: null,
question_type: generatedQuestionType,
config: null,
@@ -732,25 +729,6 @@ export function QuestionsPage() {
</p>
</div>
<div className="space-y-2">
<Label>Intensity Level</Label>
<div className="flex gap-2">
{INTENSITY_LEVELS.map((intensity) => (
<Button
key={intensity.level}
type="button"
variant={formData.intensity === intensity.level ? 'default' : 'outline'}
size="sm"
onClick={() => setFormData(d => ({ ...d, intensity: intensity.level }))}
className={formData.intensity === intensity.level ? intensity.color : ''}
title={intensity.description}
>
{intensity.level} - {intensity.label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="required_props">Required Props (optional)</Label>
<Input
@@ -1026,7 +1004,6 @@ export function QuestionsPage() {
<TableHead className="w-32">Partner Text</TableHead>
<TableHead className="w-24">Couples</TableHead>
<TableHead className="w-24">Initiator</TableHead>
<TableHead className="w-24">Intensity</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -1124,14 +1101,6 @@ export function QuestionsPage() {
<span className="text-muted-foreground text-xs">Any</span>
)}
</TableCell>
<TableCell>
<Badge
className={`${getIntensity(question.intensity).color} text-white`}
title={getIntensity(question.intensity).description}
>
{getIntensity(question.intensity).label}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button