From 1daf386765e41401a84a0bdf360bd665f634bd04 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sun, 25 Jan 2026 19:17:39 +0000 Subject: [PATCH] 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 --- CLAUDE.md | 2 + .../src/components/ai/AiGeneratorDialog.tsx | 2 +- .../src/components/ai/GenerationForm.tsx | 53 +- apps/admin/src/components/ai/QuestionList.tsx | 13 +- .../admin/src/components/ai/ReviewSummary.tsx | 9 - .../components/ai/hooks/useAiGeneration.ts | 27 +- .../src/components/content/PackFormDialog.tsx | 11 +- apps/admin/src/pages/content/DaresPage.tsx | 36 +- .../admin/src/pages/content/QuestionsPage.tsx | 49 +- apps/mobile/.eslintrc.js | 5 + apps/mobile/DESIGN.md | 229 +- apps/mobile/agents.md | 91 +- apps/mobile/app/(app)/_layout.tsx | 446 +-- apps/mobile/app/(app)/dares.tsx | 82 +- apps/mobile/app/(app)/index.tsx | 235 +- apps/mobile/app/(app)/matches.tsx | 813 ++-- apps/mobile/app/(app)/my-answers.tsx | 101 +- apps/mobile/app/(app)/onboarding.tsx | 201 +- apps/mobile/app/(app)/packs/_layout.tsx | 15 - apps/mobile/app/(app)/packs/index.tsx | 298 -- apps/mobile/app/(app)/packs/questions.tsx | 864 ----- apps/mobile/app/(app)/pairing.tsx | 104 +- apps/mobile/app/(app)/quiz.tsx | 82 +- apps/mobile/app/(app)/swipe.tsx | 453 ++- apps/mobile/app/(auth)/login.tsx | 16 +- apps/mobile/app/index.tsx | 31 +- apps/mobile/app/pack/[id].tsx | 204 +- apps/mobile/ios/Podfile.lock | 31 + .../ios/Sauci.xcodeproj/project.pbxproj | 78 +- apps/mobile/package.json | 4 + .../__tests__/components/GlassCard.test.tsx | 32 +- .../__tests__/components/GlassInput.test.tsx | 7 +- .../src/components/BoutiquePackCard.tsx | 113 +- apps/mobile/src/components/MatchConfetti.tsx | 360 ++ .../src/components/MatchNotificationModal.tsx | 49 +- .../components/discovery/CompactHeader.tsx | 195 +- .../src/components/discovery/ContentRow.tsx | 2 +- .../src/components/discovery/ContentTile.tsx | 39 +- .../feedback/QuestionFeedbackModal.tsx | 676 ++-- .../mobile/src/components/paywall/Paywall.tsx | 482 ++- .../src/components/questions/QuestionCard.tsx | 452 +++ .../questions/QuestionCardAudio.tsx | 1111 ++++++ .../questions/QuestionCardPhoto.tsx | 630 +++ .../components/questions/QuestionCardText.tsx | 496 +++ .../questions/QuestionCardWhoLikely.tsx | 478 +++ apps/mobile/src/components/questions/index.ts | 10 + .../responses/EditResponseSheet.tsx | 731 +++- .../src/components/responses/ResponseCard.tsx | 230 +- .../components/share/SharePreviewModal.tsx | 534 +++ apps/mobile/src/components/share/index.ts | 1 + .../src/components/swipe/SwipeCard.native.tsx | 678 ---- .../mobile/src/components/swipe/SwipeCard.tsx | 17 - .../src/components/swipe/SwipeCard.web.tsx | 478 --- .../src/components/swipe/SwipeCardPremium.tsx | 587 --- apps/mobile/src/components/swipe/index.ts | 2 - .../components/tutorials/MatchesTutorial.tsx | 36 +- .../components/tutorials/SwipeTutorial.tsx | 705 ---- apps/mobile/src/components/tutorials/index.ts | 1 - .../src/components/ui/DecorativeSeparator.tsx | 10 +- apps/mobile/src/components/ui/GlassCard.tsx | 150 +- apps/mobile/src/components/ui/GlassInput.tsx | 257 +- apps/mobile/src/components/ui/GlassToggle.tsx | 11 +- .../src/components/ui/IntensitySlider.tsx | 53 +- apps/mobile/src/constants/onboarding.ts | 44 + apps/mobile/src/features/chat/ChatScreen.tsx | 14 +- .../features/chat/components/ChatHeader.tsx | 44 +- .../features/chat/components/ChatMessages.tsx | 8 +- .../src/features/chat/components/InputBar.tsx | 49 +- .../features/chat/components/MatchCard.tsx | 681 +++- .../chat/components/MessageBubble.tsx | 18 +- .../chat/components/MessageContent.tsx | 15 +- apps/mobile/src/features/chat/types.ts | 5 +- .../src/features/profile/ProfileScreen.tsx | 136 +- .../profile/components/CoupleStatus.tsx | 12 +- .../profile/components/DangerZone.tsx | 493 ++- .../features/profile/components/MenuItem.tsx | 2 + .../components/NotificationSettings.tsx | 16 +- .../profile/components/PrivacySettings.tsx | 2 +- .../profile/components/ProfileHeader.tsx | 54 +- .../components/RelationshipDangerZone.tsx | 437 ++- .../profile/components/ResetProgress.tsx | 247 +- .../profile/components/ScreenHeader.tsx | 18 +- .../profile/components/SettingsSection.tsx | 2 +- .../profile/components/SubscriptionCard.tsx | 92 +- .../profile/screens/ProfileSettingsScreen.tsx | 84 +- .../profile/screens/SaveAccountScreen.tsx | 2 +- .../profile/screens/SupportScreen.tsx | 12 +- apps/mobile/src/hooks/index.ts | 8 + apps/mobile/src/hooks/useAudioRecorder.ts | 240 ++ .../src/hooks/useResponseMediaUpload.ts | 191 + apps/mobile/src/lib/imageCache.ts | 16 +- apps/mobile/src/lib/notifications.ts | 73 +- apps/mobile/src/lib/onboardingPaywallSeen.ts | 49 + apps/mobile/src/lib/revenuecat.ts | 13 +- apps/mobile/src/lib/swipeTutorialSeen.ts | 51 - apps/mobile/src/store/matchStore.ts | 210 +- .../src/store/notificationPreferencesStore.ts | 1 + apps/mobile/src/store/responsesStore.ts | 23 +- apps/mobile/src/store/subscriptionStore.ts | 108 +- apps/mobile/src/theme/index.ts | 61 +- apps/mobile/src/types/index.ts | 75 +- .../send-match-notification-digest/index.ts | 117 +- .../send-nudge-notification/index.ts | 229 ++ .../functions/submit-response/index.ts | 240 +- .../functions/update-response/index.ts | 250 +- .../20260125100000_add_question_types.sql | 111 + ...question_type_to_recommended_questions.sql | 227 ++ ...0125120000_session_based_notifications.sql | 109 + .../20260125200000_add_onboarding_version.sql | 14 + .../20260125210000_add_nudge_feature.sql | 14 + docs/live-draw-feature-plan.md | 202 + package-lock.json | 3372 ++++++++++++++++- packages/shared/src/types/index.ts | 29 +- packages/shared/src/types/supabase.ts | 70 +- 114 files changed, 14877 insertions(+), 7911 deletions(-) create mode 100644 apps/mobile/.eslintrc.js delete mode 100644 apps/mobile/app/(app)/packs/_layout.tsx delete mode 100644 apps/mobile/app/(app)/packs/index.tsx delete mode 100644 apps/mobile/app/(app)/packs/questions.tsx create mode 100644 apps/mobile/src/components/MatchConfetti.tsx create mode 100644 apps/mobile/src/components/questions/QuestionCard.tsx create mode 100644 apps/mobile/src/components/questions/QuestionCardAudio.tsx create mode 100644 apps/mobile/src/components/questions/QuestionCardPhoto.tsx create mode 100644 apps/mobile/src/components/questions/QuestionCardText.tsx create mode 100644 apps/mobile/src/components/questions/QuestionCardWhoLikely.tsx create mode 100644 apps/mobile/src/components/questions/index.ts create mode 100644 apps/mobile/src/components/share/SharePreviewModal.tsx create mode 100644 apps/mobile/src/components/share/index.ts delete mode 100755 apps/mobile/src/components/swipe/SwipeCard.native.tsx delete mode 100644 apps/mobile/src/components/swipe/SwipeCard.tsx delete mode 100644 apps/mobile/src/components/swipe/SwipeCard.web.tsx delete mode 100644 apps/mobile/src/components/swipe/SwipeCardPremium.tsx delete mode 100644 apps/mobile/src/components/swipe/index.ts delete mode 100644 apps/mobile/src/components/tutorials/SwipeTutorial.tsx create mode 100644 apps/mobile/src/constants/onboarding.ts create mode 100644 apps/mobile/src/hooks/useAudioRecorder.ts create mode 100644 apps/mobile/src/hooks/useResponseMediaUpload.ts create mode 100644 apps/mobile/src/lib/onboardingPaywallSeen.ts delete mode 100644 apps/mobile/src/lib/swipeTutorialSeen.ts create mode 100644 apps/supabase/functions/send-nudge-notification/index.ts create mode 100644 apps/supabase/migrations/20260125100000_add_question_types.sql create mode 100644 apps/supabase/migrations/20260125110000_add_question_type_to_recommended_questions.sql create mode 100644 apps/supabase/migrations/20260125120000_session_based_notifications.sql create mode 100644 apps/supabase/migrations/20260125200000_add_onboarding_version.sql create mode 100644 apps/supabase/migrations/20260125210000_add_nudge_feature.sql create mode 100644 docs/live-draw-feature-plan.md diff --git a/CLAUDE.md b/CLAUDE.md index 0e9ff16..d2dd7ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,6 +134,7 @@ Edge functions in `apps/supabase/functions/` handle complex operations: - `delete-relationship` - Deletes couple data (matches, messages, media) while keeping accounts - `delete-account` - Permanently deletes user account and all associated data (Apple App Store requirement) - `send-notification` - Push notifications for matches +- `send-nudge-notification` - Partner nudge notifications (rate limited to once per 12 hours) - `revenuecat-webhook` - Subscription webhook handler - `sync-subscription` - Syncs RevenueCat subscription status - `redeem-code` - Redeems promotional codes for premium access (website only) @@ -165,6 +166,7 @@ This approach: | `revenuecat-webhook` | false | Webhook signature validation | | `send-notification` | false | Internal trigger (no auth) | | `send-message-notification` | false | Internal trigger (no auth) | +| `send-nudge-notification` | false | Manual via getUser() | ### Database Best Practices - **Always use `.maybeSingle()` instead of `.single()`** when the row might not exist. `.single()` throws an error if 0 or >1 rows are returned. diff --git a/apps/admin/src/components/ai/AiGeneratorDialog.tsx b/apps/admin/src/components/ai/AiGeneratorDialog.tsx index d36df69..bc5b069 100644 --- a/apps/admin/src/components/ai/AiGeneratorDialog.tsx +++ b/apps/admin/src/components/ai/AiGeneratorDialog.tsx @@ -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} /> diff --git a/apps/admin/src/components/ai/GenerationForm.tsx b/apps/admin/src/components/ai/GenerationForm.tsx index 20c0528..3243666 100644 --- a/apps/admin/src/components/ai/GenerationForm.tsx +++ b/apps/admin/src/components/ai/GenerationForm.tsx @@ -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 (
{/* Question Count (questions only) */} @@ -53,42 +49,15 @@ export function GenerationForm({
)} - {/* Intimacy Level Selector (questions only) */} - {type === 'questions' && ( -
- -
- {TONE_LEVELS.map((t) => ( - - ))} -
-

- {TONE_LEVELS.find(t => t.level === config.tone)?.description} -

-
- )} - - {/* Wild/Explicit Toggle (for other generators) */} - {(type === 'category-ideas' || type === 'category-pack-ideas' || type === 'pack') && ( -
- onToneChange(checked ? 5 : 3)} - /> - -
- )} + {/* Explicit Toggle */} +
+ + +
{/* Crude Language Override Toggle */}
diff --git a/apps/admin/src/components/ai/QuestionList.tsx b/apps/admin/src/components/ai/QuestionList.tsx index 973959d..25a30bb 100644 --- a/apps/admin/src/components/ai/QuestionList.tsx +++ b/apps/admin/src/components/ai/QuestionList.tsx @@ -10,7 +10,6 @@ import { Bot, } from 'lucide-react'; import { - INTENSITY_LEVELS, type GeneratedQuestion, type QuestionReview, type CouncilGenerationResult, @@ -199,16 +198,6 @@ function QuestionItem({ )}
- {/* Intensity Badge */} - - {question.intensity} - {INTENSITY_LEVELS[question.intensity - 1]?.label || 'Unknown'} - - {/* Source Generator Badge (cherry-pick mode) */} {selectionMode === 'cherry_pick' && question.sourceGeneratorModel && ( @@ -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}` : '' } > diff --git a/apps/admin/src/components/ai/ReviewSummary.tsx b/apps/admin/src/components/ai/ReviewSummary.tsx index a16280f..cef67fb 100644 --- a/apps/admin/src/components/ai/ReviewSummary.tsx +++ b/apps/admin/src/components/ai/ReviewSummary.tsx @@ -91,15 +91,6 @@ export function ReviewSummary({

)} - {/* Intensity distribution for cherry-pick */} - {selectionMode === 'cherry_pick' && cherryPickResult && Object.keys(cherryPickResult.intensityDistribution).length > 0 && ( -

- Intensity distribution: {Object.entries(cherryPickResult.intensityDistribution) - .sort(([a], [b]) => Number(a) - Number(b)) - .map(([level, count]) => `L${level}:${count}`) - .join(', ')} -

- )}
)} diff --git a/apps/admin/src/components/ai/hooks/useAiGeneration.ts b/apps/admin/src/components/ai/hooks/useAiGeneration.ts index ba97914..b978f7c 100644 --- a/apps/admin/src/components/ai/hooks/useAiGeneration.ts +++ b/apps/admin/src/components/ai/hooks/useAiGeneration.ts @@ -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({ 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, diff --git a/apps/admin/src/components/content/PackFormDialog.tsx b/apps/admin/src/components/content/PackFormDialog.tsx index 3ffa055..3d82d07 100644 --- a/apps/admin/src/components/content/PackFormDialog.tsx +++ b/apps/admin/src/components/content/PackFormDialog.tsx @@ -203,11 +203,12 @@ export function PackFormDialog({ { - 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); }} /> diff --git a/apps/admin/src/pages/content/DaresPage.tsx b/apps/admin/src/pages/content/DaresPage.tsx index 76e25f8..a4b4416 100644 --- a/apps/admin/src/pages/content/DaresPage.tsx +++ b/apps/admin/src/pages/content/DaresPage.tsx @@ -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() { /> -
- -
- {INTENSITY_LEVELS.map((intensity) => ( - - ))} -
-
-
Partner Text Couples Initiator - Intensity Actions @@ -1124,14 +1101,6 @@ export function QuestionsPage() { Any )} - - - {getIntensity(question.intensity).label} - -