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
+12954 -5988

No files matched your search

+2
View File
@@ -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-relationship` - Deletes couple data (matches, messages, media) while keeping accounts
- `delete-account` - Permanently deletes user account and all associated data (Apple App Store requirement) - `delete-account` - Permanently deletes user account and all associated data (Apple App Store requirement)
- `send-notification` - Push notifications for matches - `send-notification` - Push notifications for matches
- `send-nudge-notification` - Partner nudge notifications (rate limited to once per 12 hours)
- `revenuecat-webhook` - Subscription webhook handler - `revenuecat-webhook` - Subscription webhook handler
- `sync-subscription` - Syncs RevenueCat subscription status - `sync-subscription` - Syncs RevenueCat subscription status
- `redeem-code` - Redeems promotional codes for premium access (website only) - `redeem-code` - Redeems promotional codes for premium access (website only)
@@ -165,6 +166,7 @@ This approach:
| `revenuecat-webhook` | false | Webhook signature validation | | `revenuecat-webhook` | false | Webhook signature validation |
| `send-notification` | false | Internal trigger (no auth) | | `send-notification` | false | Internal trigger (no auth) |
| `send-message-notification` | false | Internal trigger (no auth) | | `send-message-notification` | false | Internal trigger (no auth) |
| `send-nudge-notification` | false | Manual via getUser() |
### Database Best Practices ### 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. - **Always use `.maybeSingle()` instead of `.single()`** when the row might not exist. `.single()` throws an error if 0 or >1 rows are returned.
@@ -114,7 +114,7 @@ export function AiGeneratorDialog({
context={context} context={context}
config={generation.config} config={generation.config}
onCountChange={generation.setCount} onCountChange={generation.setCount}
onToneChange={generation.setTone} onExplicitChange={generation.setExplicit}
onCrudeLangChange={generation.setCrudeLang} onCrudeLangChange={generation.setCrudeLang}
onInspirationChange={generation.setInspiration} onInspirationChange={generation.setInspiration}
/> />
@@ -1,9 +1,7 @@
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 { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { TONE_LEVELS, type ToneLevel } from '@/lib/openai';
import type { GeneratorType, GeneratorContext, GenerationConfig } from './hooks/useAiGeneration'; import type { GeneratorType, GeneratorContext, GenerationConfig } from './hooks/useAiGeneration';
// ============================================================================= // =============================================================================
@@ -15,7 +13,7 @@ interface GenerationFormProps {
context?: GeneratorContext; context?: GeneratorContext;
config: GenerationConfig; config: GenerationConfig;
onCountChange: (count: number) => void; onCountChange: (count: number) => void;
onToneChange: (tone: ToneLevel) => void; onExplicitChange: (isExplicit: boolean) => void;
onCrudeLangChange: (crudeLang: boolean) => void; onCrudeLangChange: (crudeLang: boolean) => void;
onInspirationChange: (inspiration: string) => void; onInspirationChange: (inspiration: string) => void;
} }
@@ -29,12 +27,10 @@ export function GenerationForm({
context, context,
config, config,
onCountChange, onCountChange,
onToneChange, onExplicitChange,
onCrudeLangChange, onCrudeLangChange,
onInspirationChange, onInspirationChange,
}: GenerationFormProps) { }: GenerationFormProps) {
const isExplicit = config.tone >= 4;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Question Count (questions only) */} {/* Question Count (questions only) */}
@@ -53,42 +49,15 @@ export function GenerationForm({
</div> </div>
)} )}
{/* Intimacy Level Selector (questions only) */} {/* Explicit Toggle */}
{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"> <div className="flex items-center space-x-2">
<Switch <Switch
id="explicit-mode" id="explicit-mode"
checked={isExplicit} checked={config.isExplicit}
onCheckedChange={(checked: boolean) => onToneChange(checked ? 5 : 3)} onCheckedChange={onExplicitChange}
/> />
<Label htmlFor="explicit-mode">Include Wild/Adult Ideas</Label> <Label htmlFor="explicit-mode">Explicit Content</Label>
</div> </div>
)}
{/* Crude Language Override Toggle */} {/* Crude Language Override Toggle */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
+1 -12
View File
@@ -10,7 +10,6 @@ import {
Bot, Bot,
} from 'lucide-react'; } from 'lucide-react';
import { import {
INTENSITY_LEVELS,
type GeneratedQuestion, type GeneratedQuestion,
type QuestionReview, type QuestionReview,
type CouncilGenerationResult, type CouncilGenerationResult,
@@ -199,16 +198,6 @@ function QuestionItem({
)} )}
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> <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) */} {/* Source Generator Badge (cherry-pick mode) */}
{selectionMode === 'cherry_pick' && question.sourceGeneratorModel && ( {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"> <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' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`} }`}
title={review.scores 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> </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>
)} )}
</div> </div>
@@ -10,7 +10,6 @@ import {
type GeneratedQuestion, type GeneratedQuestion,
type GeneratedCategoryIdea, type GeneratedCategoryIdea,
type GeneratedPackIdea, type GeneratedPackIdea,
type ToneLevel,
type QuestionReview, type QuestionReview,
type CouncilGenerationResult, type CouncilGenerationResult,
type GenerationCandidate, type GenerationCandidate,
@@ -38,7 +37,7 @@ export interface GeneratorContext {
export interface GenerationConfig { export interface GenerationConfig {
count: number; count: number;
tone: ToneLevel; isExplicit: boolean;
crudeLang: boolean; crudeLang: boolean;
inspiration: string; inspiration: string;
} }
@@ -77,7 +76,7 @@ export function useAiGeneration(
// Config state // Config state
const [config, setConfig] = useState<GenerationConfig>({ const [config, setConfig] = useState<GenerationConfig>({
count: 10, count: 10,
tone: context?.isExplicit ? 5 : 2, isExplicit: Boolean(context?.isExplicit),
crudeLang: false, crudeLang: false,
inspiration: '', inspiration: '',
}); });
@@ -118,14 +117,12 @@ export function useAiGeneration(
// Council config // Council config
const councilConfig = getCouncilConfig(); const councilConfig = getCouncilConfig();
// Update tone when context changes // Update explicit flag when context changes
useEffect(() => { useEffect(() => {
if (type === 'questions') {
setConfig(prev => ({ setConfig(prev => ({
...prev, ...prev,
tone: context?.isExplicit ? 5 : 2, isExplicit: Boolean(context?.isExplicit),
})); }));
}
}, [context?.isExplicit, type]); }, [context?.isExplicit, type]);
// Config setters // Config setters
@@ -133,8 +130,8 @@ export function useAiGeneration(
setConfig(prev => ({ ...prev, count })); setConfig(prev => ({ ...prev, count }));
}, []); }, []);
const setTone = useCallback((tone: ToneLevel) => { const setExplicit = useCallback((isExplicit: boolean) => {
setConfig(prev => ({ ...prev, tone })); setConfig(prev => ({ ...prev, isExplicit }));
}, []); }, []);
const setCrudeLang = useCallback((crudeLang: boolean) => { const setCrudeLang = useCallback((crudeLang: boolean) => {
@@ -219,7 +216,7 @@ export function useAiGeneration(
setFilter('all'); setFilter('all');
setSourceModel(null); setSourceModel(null);
const isExplicit = config.tone >= 4; const isExplicit = config.isExplicit;
try { try {
if (type === 'pack') { if (type === 'pack') {
@@ -253,7 +250,7 @@ export function useAiGeneration(
context.packName, context.packName,
config.count, config.count,
undefined, undefined,
config.tone, isExplicit ? 5 : 2,
context.packDescription || undefined, context.packDescription || undefined,
context.existingQuestions, context.existingQuestions,
config.crudeLang, config.crudeLang,
@@ -369,7 +366,7 @@ export function useAiGeneration(
// Config // Config
config, config,
setCount, setCount,
setTone, setExplicit,
setCrudeLang, setCrudeLang,
setInspiration, setInspiration,
@@ -203,11 +203,12 @@ export function PackFormDialog({
<Switch <Switch
checked={formData.is_public} checked={formData.is_public}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
setField('is_public', checked); const next = {
// Clear scheduled release if making public immediately ...formData,
if (checked) { is_public: checked,
setField('scheduled_release_at', null); ...(checked ? { scheduled_release_at: null } : {}),
} };
onFormChange(next);
}} }}
/> />
</div> </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 { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { IconPreview } from '@/components/ui/icon-picker'; import { IconPreview } from '@/components/ui/icon-picker';
import { INTENSITY_LEVELS } from '@/lib/openai';
// ============================================================================= // =============================================================================
// Types // Types
@@ -71,8 +70,7 @@ const DURATION_OPTIONS = [
{ value: '168', label: '1 week' }, { value: '168', label: '1 week' },
]; ];
// Helper to get intensity info by level (1-indexed) const getDefaultIntensity = (isExplicit?: boolean) => (isExplicit ? 4 : 2);
const getIntensity = (level: number) => INTENSITY_LEVELS[level - 1] || INTENSITY_LEVELS[0];
// Format duration for display // Format duration for display
const formatDuration = (hours: number | null): string => { const formatDuration = (hours: number | null): string => {
@@ -107,7 +105,7 @@ export function DaresPage() {
suggested_duration_hours: string; suggested_duration_hours: string;
}>({ }>({
text: '', text: '',
intensity: 1, intensity: getDefaultIntensity(),
suggested_duration_hours: '', suggested_duration_hours: '',
}); });
@@ -169,7 +167,7 @@ export function DaresPage() {
const openCreateDialog = () => { const openCreateDialog = () => {
setEditingDare(null); setEditingDare(null);
setFormData({ text: '', intensity: 1, suggested_duration_hours: '' }); setFormData({ text: '', intensity: getDefaultIntensity(pack?.is_explicit), suggested_duration_hours: '' });
setDialogOpen(true); setDialogOpen(true);
}; };
@@ -343,25 +341,6 @@ export function DaresPage() {
/> />
</div> </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"> <div className="space-y-2">
<Label>Suggested Duration</Label> <Label>Suggested Duration</Label>
<Select <Select
@@ -467,7 +446,6 @@ export function DaresPage() {
</TableHead> </TableHead>
<TableHead className="w-12">#</TableHead> <TableHead className="w-12">#</TableHead>
<TableHead>Dare</TableHead> <TableHead>Dare</TableHead>
<TableHead className="w-24">Intensity</TableHead>
<TableHead className="w-24">Duration</TableHead> <TableHead className="w-24">Duration</TableHead>
<TableHead className="w-24">Actions</TableHead> <TableHead className="w-24">Actions</TableHead>
</TableRow> </TableRow>
@@ -491,14 +469,6 @@ export function DaresPage() {
<TableCell> <TableCell>
<span className="line-clamp-2">{dare.text}</span> <span className="line-clamp-2">{dare.text}</span>
</TableCell> </TableCell>
<TableCell>
<Badge
className={`${getIntensity(dare.intensity).color} text-white`}
title={getIntensity(dare.intensity).description}
>
{getIntensity(dare.intensity).label}
</Badge>
</TableCell>
<TableCell> <TableCell>
{dare.suggested_duration_hours ? ( {dare.suggested_duration_hours ? (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
+9 -40
View File
@@ -73,12 +73,8 @@ interface Question {
config?: QuestionConfig | null; 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 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' },
@@ -138,7 +134,7 @@ export function QuestionsPage() {
}>({ }>({
text: '', text: '',
partner_text: '', partner_text: '',
intensity: 1, intensity: getDefaultIntensity(),
allowed_couple_genders: [], allowed_couple_genders: [],
target_user_genders: [], target_user_genders: [],
required_props: '', required_props: '',
@@ -276,7 +272,7 @@ export function QuestionsPage() {
setFormData({ setFormData({
text: '', text: '',
partner_text: '', partner_text: '',
intensity: 1, intensity: getDefaultIntensity(pack?.is_explicit),
allowed_couple_genders: [], allowed_couple_genders: [],
target_user_genders: [], target_user_genders: [],
required_props: '', 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 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 () => {
@@ -414,7 +411,7 @@ 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: q.intensity, intensity: defaultIntensity,
inverse_of: null, inverse_of: null,
question_type: generatedQuestionType, question_type: generatedQuestionType,
config: null, config: null,
@@ -434,7 +431,7 @@ 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: primary.intensity, intensity: defaultIntensity,
inverse_of: null, inverse_of: null,
question_type: generatedQuestionType, question_type: generatedQuestionType,
config: null, config: null,
@@ -452,7 +449,7 @@ 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: inverse.intensity, intensity: defaultIntensity,
inverse_of: primaryId, inverse_of: primaryId,
question_type: generatedQuestionType, question_type: generatedQuestionType,
config: null, config: null,
@@ -470,7 +467,7 @@ 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: extra.intensity, intensity: defaultIntensity,
inverse_of: null, inverse_of: null,
question_type: generatedQuestionType, question_type: generatedQuestionType,
config: null, config: null,
@@ -486,7 +483,7 @@ 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: pair[0].intensity, intensity: defaultIntensity,
inverse_of: null, inverse_of: null,
question_type: generatedQuestionType, question_type: generatedQuestionType,
config: null, config: null,
@@ -732,25 +729,6 @@ export function QuestionsPage() {
</p> </p>
</div> </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"> <div className="space-y-2">
<Label htmlFor="required_props">Required Props (optional)</Label> <Label htmlFor="required_props">Required Props (optional)</Label>
<Input <Input
@@ -1026,7 +1004,6 @@ export function QuestionsPage() {
<TableHead className="w-32">Partner Text</TableHead> <TableHead className="w-32">Partner Text</TableHead>
<TableHead className="w-24">Couples</TableHead> <TableHead className="w-24">Couples</TableHead>
<TableHead className="w-24">Initiator</TableHead> <TableHead className="w-24">Initiator</TableHead>
<TableHead className="w-24">Intensity</TableHead>
<TableHead className="w-24">Actions</TableHead> <TableHead className="w-24">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -1124,14 +1101,6 @@ export function QuestionsPage() {
<span className="text-muted-foreground text-xs">Any</span> <span className="text-muted-foreground text-xs">Any</span>
)} )}
</TableCell> </TableCell>
<TableCell>
<Badge
className={`${getIntensity(question.intensity).color} text-white`}
title={getIntensity(question.intensity).description}
>
{getIntensity(question.intensity).label}
</Badge>
</TableCell>
<TableCell> <TableCell>
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button
+5
View File
@@ -0,0 +1,5 @@
// https://docs.expo.dev/guides/using-eslint/
module.exports = {
extends: 'expo',
ignorePatterns: ['/dist/*'],
};
+63 -54
View File
@@ -10,11 +10,11 @@ A boutique, premium design framework for the Sauci couples intimacy app.
**Key Principles:** **Key Principles:**
- Glass-morphism for depth and elegance - Dark, velvety backgrounds with subtle gradient atmosphere
- Dark backgrounds with warm accents - Solid, high-contrast surfaces (no glass or blur effects)
- Primary gradient (rose → purple) as the core brand signature - Primary gradient (rose → purple) as the core brand signature
- Gold reserved for premium/dares features only - Gold reserved for premium/dares features only
- Gradients for visual interest and brand recognition - Soft glows and shadows for depth, not transparency
--- ---
@@ -26,12 +26,12 @@ The core brand identity. Used for primary actions, key UI elements, and brand re
| Name | Hex | RGB | Usage | | Name | Hex | RGB | Usage |
|------|-----|-----|-------| |------|-----|-----|-------|
| **Primary** | `#e94560` | 233, 69, 96 | Primary buttons, active states, key accents | | **Primary** | `#e1306c` | 225, 48, 108 | Primary buttons, active states, key accents |
| **Primary Dark** | `#c73a52` | 199, 58, 82 | Pressed states, darker variants | | **Primary Dark** | `#c12055` | 193, 32, 85 | Pressed states, darker variants |
| **Secondary** | `#9b59b6` | 155, 89, 182 | Secondary accents, gradient endpoints | | **Secondary** | `#9b59b6` | 155, 89, 182 | Secondary accents, gradient endpoints |
| **Secondary Dark** | `#8e44ad` | 142, 68, 173 | Pressed states, darker variants | | **Secondary Dark** | `#8e44ad` | 142, 68, 173 | Pressed states, darker variants |
**Primary Gradient:** `#e94560 → #9b59b6` (Rose to Purple) **Primary Gradient:** `#e1306c → #9b59b6` (Rose to Purple)
- Direction: Left to right (horizontal) or top to bottom (vertical) - Direction: Left to right (horizontal) or top to bottom (vertical)
- Use for: Primary buttons, match celebrations, brand moments - Use for: Primary buttons, match celebrations, brand moments
@@ -53,10 +53,10 @@ Each core feature has a designated accent color for consistency and recognition.
| Feature | Primary Color | Hex | Gradient | | Feature | Primary Color | Hex | Gradient |
|---------|---------------|-----|----------| |---------|---------------|-----|----------|
| **Swipe/Match** | Primary | `#e94560` | Primary gradient (rose → purple) | | **Swipe/Match** | Primary | `#e1306c` | Primary gradient (rose → purple) |
| **Quiz** | Rose | `#E8A4AE` | `#E8A4AE → #D4919B` | | **Quiz** | Rose | `#E8A4AE` | `#E8A4AE → #D4919B` |
| **Dares** | Gold | `#D4AF37` | `#D4AF37 → #B8860B` | | **Dares** | Gold | `#D4AF37` | `#D4AF37 → #B8860B` |
| **Chat** | Primary | `#e94560` | Primary gradient | | **Chat** | Primary | `#e1306c` | Primary gradient |
| **Profile** | Secondary | `#9b59b6` | Secondary gradient | | **Profile** | Secondary | `#9b59b6` | Secondary gradient |
**Usage Rules:** **Usage Rules:**
@@ -71,22 +71,31 @@ Dark, rich backgrounds that make content pop.
| Name | Hex | RGBA | Usage | | Name | Hex | RGBA | Usage |
|------|-----|------|-------| |------|-----|------|-------|
| **Background** | `#0d0d1a` | 13, 13, 26 | Primary app background | | **Background** | `#0e0e11` | 14, 14, 17 | Primary app background |
| **Background Light** | `#1a1a2e` | 26, 26, 46 | Cards, elevated surfaces | | **Background Light** | `#17171c` | 23, 23, 28 | Cards, elevated surfaces |
| **Surface** | — | `rgba(22, 33, 62, 0.6)` | Glass overlays | | **Surface** | `#17171c` | 23, 23, 28 | Primary surface color |
| **Surface Solid** | `#16213e` | 22, 33, 62 | Non-transparent surfaces | | **Surface Solid** | `#17171c` | 23, 23, 28 | Opaque surfaces |
| **Border** | `#303036` | 48, 48, 54 | Dividers, outlines, input borders |
### 5. Glass/Surface Colors ### 5. Tile/Category Colors (Home Screen)
For the glass-morphism effect that defines the UI. Discovery/Home tiles use vibrant, solid colors for quick recognition.
| Name | RGBA | Usage | | Token | Hex |
|------|------|-------| |-------|-----|
| **Glass Background** | `rgba(22, 33, 62, 0.4)` | Standard glass cards | | **teal** | `#14B8A6` |
| **Glass Background Heavy** | `rgba(22, 33, 62, 0.6)` | Emphasized glass cards | | **purple** | `#8B5CF6` |
| **Glass Border** | `rgba(255, 255, 255, 0.08)` | Subtle borders | | **orange** | `#F97316` |
| **Glass Border Light** | `rgba(255, 255, 255, 0.12)` | Prominent borders | | **coral** | `#F87171` |
| **Glass Highlight** | `rgba(255, 255, 255, 0.05)` | Top edge shine | | **emerald** | `#10B981` |
| **indigo** | `#6366F1` |
| **rose** | `#EC4899` |
| **amber** | `#F59E0B` |
**Usage Rules:**
- Tiles are solid fills with a subtle dark overlay for depth
- Category colors are consistent across the app
- Avoid gradients on tiles unless it is a premium moment
### 6. Text Colors ### 6. Text Colors
@@ -127,9 +136,9 @@ Consistent across all swipe interactions:
### Brand Gradients ### Brand Gradients
``` ```
Primary: #e94560 → #9b59b6 (Rose to Purple) Primary: #e1306c → #9b59b6 (Rose to Purple)
Primary Subtle: rgba(233, 69, 96, 0.8) → rgba(155, 89, 182, 0.8) Primary Subtle: rgba(225, 48, 108, 0.8) → rgba(155, 89, 182, 0.8)
Primary Reverse: #9b59b6 → #e94560 (Purple to Rose) Primary Reverse: #9b59b6 → #e1306c (Purple to Rose)
``` ```
### Premium Gradients ### Premium Gradients
@@ -142,8 +151,8 @@ Premium Rose: #E8A4AE → #D4919B
### Background Gradients ### Background Gradients
``` ```
Background: #1a1a2e → #0d0d1a (Light to Dark) Background: #0e0e11 → #1f1417 → #0e0e11
Background Reverse: #0d0d1a → #1a1a2e (Dark to Light) Background Reverse: #0e0e11 → #1f1417 → #0e0e11
``` ```
### Boutique Card Gradients ### Boutique Card Gradients
@@ -168,7 +177,7 @@ Boutique Amethyst: rgba(142, 68, 173, 0.4) → rgba(44, 62, 80, 0.4)
| Variant | Background | Text | Border | | Variant | Background | Text | Border |
|---------|------------|------|--------| |---------|------------|------|--------|
| **Primary** | Primary gradient | White | None | | **Primary** | Primary gradient | White | None |
| **Secondary** | Glass background | White | Glass border | | **Secondary** | Surface | White | Border |
| **Ghost** | Transparent | Primary | None | | **Ghost** | Transparent | Primary | None |
| **Danger** | Error gradient | White | None | | **Danger** | Error gradient | White | None |
| **Feature** | Feature color gradient | White | None | | **Feature** | Feature color gradient | White | None |
@@ -177,18 +186,18 @@ Boutique Amethyst: rgba(142, 68, 173, 0.4) → rgba(44, 62, 80, 0.4)
| Variant | Background | Border | Use Case | | Variant | Background | Border | Use Case |
|---------|------------|--------|----------| |---------|------------|--------|----------|
| **Glass** | Glass background | Glass border | Standard content | | **Solid** | Surface | Border | Standard content |
| **Glass Elevated** | Glass background heavy | Glass border light | Emphasized content | | **Raised** | Surface | Border | Emphasized content + shadow |
| **Solid** | Background light | Glass border | Non-blur contexts | | **Tinted** | Category color | None | Home tiles, feature promos |
### Inputs ### Inputs
| State | Border Color | Background | | State | Border Color | Background |
|-------|--------------|------------| |-------|--------------|------------|
| **Default** | Glass border | Glass background | | **Default** | Border | Surface |
| **Focused** | Primary | Glass background | | **Focused** | Primary | Surface |
| **Error** | Error | Glass background | | **Error** | Error | Surface |
| **Disabled** | Glass border | Glass background (opacity 0.5) | | **Disabled** | Border | Surface (opacity 0.6) |
--- ---
@@ -196,9 +205,9 @@ Boutique Amethyst: rgba(142, 68, 173, 0.4) → rgba(44, 62, 80, 0.4)
| Element | Color | | Element | Color |
|---------|-------| |---------|-------|
| **Background** | `rgba(13, 13, 26, 0.92)` with blur | | **Background** | `#0e0e11` |
| **Top Border** | `rgba(233, 69, 96, 0.2)` (primary tint) | | **Top Border** | `rgba(225, 48, 108, 0.2)` (primary tint) |
| **Active Icon** | Primary `#e94560` | | **Active Icon** | Primary `#e1306c` |
| **Inactive Icon** | Text secondary | | **Inactive Icon** | Text secondary |
| **Badge** | Rose `#E8A4AE` | | **Badge** | Rose `#E8A4AE` |
@@ -208,10 +217,10 @@ The main "play" button uses the primary brand gradient for maximum brand presenc
| Element | Color | | Element | Color |
|---------|-------| |---------|-------|
| **Glow** | Primary glow `rgba(233, 69, 96, 0.3)` | | **Glow** | Primary glow `rgba(225, 48, 108, 0.3)` |
| **Border** | `rgba(233, 69, 96, 0.4)` | | **Border** | `rgba(225, 48, 108, 0.4)` |
| **Highlight** | Primary → Secondary gradient | | **Highlight** | Primary → Secondary gradient |
| **Icon** | Primary `#e94560` | | **Icon** | Primary `#e1306c` |
## Radial Menu ## Radial Menu
@@ -219,7 +228,7 @@ Feature-specific colors for quick action items:
| Item | Variant | Color | | Item | Variant | Color |
|------|---------|-------| |------|---------|-------|
| **Match** | Primary | `#e94560` (core feature) | | **Match** | Primary | `#e1306c` (core feature) |
| **Dares** | Gold | `#D4AF37` (premium/adventure) | | **Dares** | Gold | `#D4AF37` (premium/adventure) |
| **Quiz** | Rose | `#E8A4AE` (intimacy) | | **Quiz** | Rose | `#E8A4AE` (intimacy) |
@@ -278,17 +287,17 @@ Feature-specific colors for quick action items:
| Style | Size | Weight | Line Height | Usage | | Style | Size | Weight | Line Height | Usage |
|-------|------|--------|-------------|-------| |-------|------|--------|-------------|-------|
| `largeTitle` | 34px | Bold | 41px | Screen titles | | `largeTitle` | 32px | Bold | 40px | Screen titles |
| `title1` | 28px | Bold | 34px | Section headers | | `title1` | 28px | Bold | 34px | Section headers |
| `title2` | 22px | Bold | 28px | Card titles | | `title2` | 24px | Bold | 30px | Card titles |
| `title3` | 20px | SemiBold | 25px | Subsection headers | | `title3` | 20px | SemiBold | 26px | Subsection headers |
| `headline` | 17px | SemiBold | 22px | Emphasized body | | `headline` | 17px | SemiBold | 22px | Emphasized body |
| `body` | 17px | Regular | 22px | Body text | | `body` | 16px | Regular | 22px | Body text |
| `callout` | 16px | Regular | 21px | Secondary body | | `callout` | 15px | Regular | 20px | Secondary body |
| `subhead` | 15px | Regular | 20px | Supporting text | | `subhead` | 14px | Regular | 20px | Supporting text |
| `footnote` | 13px | Regular | 18px | Captions | | `footnote` | 13px | Regular | 18px | Captions |
| `caption1` | 12px | Regular | 16px | Small labels | | `caption1` | 12px | Regular | 16px | Small labels |
| `caption2` | 11px | Regular | 13px | Tiny text | | `caption2` | 11px | Regular | 14px | Tiny text |
--- ---
@@ -308,14 +317,14 @@ Feature-specific colors for quick action items:
## Platform Considerations ## Platform Considerations
### iOS ### iOS
- Use BlurView for glass effects - Favor solid surfaces and shadows for depth
- Haptic feedback on interactions - Haptic feedback on interactions
- Native-feeling animations - Native-feeling animations
### Android ### Android
- Fallback to solid semi-transparent backgrounds - Match iOS surface colors and shadows
- Reduced blur intensity for performance - Avoid blur and heavy transparency for performance
- Adjusted shadow rendering - Adjust shadow rendering as needed
--- ---
@@ -324,7 +333,7 @@ Feature-specific colors for quick action items:
### Do ### Do
- Use the primary gradient for main CTAs - Use the primary gradient for main CTAs
- Apply feature colors consistently within a feature - Apply feature colors consistently within a feature
- Use glass effects for depth and hierarchy - Use solid surfaces with soft shadows for depth
- Keep text high-contrast (white on dark) - Keep text high-contrast (white on dark)
- Use gold/rose for premium moments - Use gold/rose for premium moments
@@ -332,8 +341,8 @@ Feature-specific colors for quick action items:
- Mix feature colors within a single screen - Mix feature colors within a single screen
- Use more than 2-3 accent colors per screen - Use more than 2-3 accent colors per screen
- Apply the primary gradient to everything - Apply the primary gradient to everything
- Use glass, blur, or frosted effects
- Use pure black (#000000) for backgrounds - Use pure black (#000000) for backgrounds
- Forget to include the glow effect on premium elements
--- ---
+10 -39
View File
@@ -8,8 +8,8 @@ This is an Expo React Native app using:
- **Routing**: expo-router with file-based routing in `app/` directory - **Routing**: expo-router with file-based routing in `app/` directory
- **State**: Zustand stores in `src/store/index.ts` - **State**: Zustand stores in `src/store/index.ts`
- **Backend**: Supabase client in `src/lib/supabase.ts` - **Backend**: Supabase client in `src/lib/supabase.ts`
- **UI**: Glass-morphism themed components in `src/components/ui/`
- **Theme**: Centralized theme in `src/theme/index.ts` Design guidance lives in `apps/mobile/DESIGN.md`.
## Directory Structure ## Directory Structure
@@ -19,27 +19,18 @@ app/ # Expo Router routes
(app)/ # Protected app screens (tabs) (app)/ # Protected app screens (tabs)
src/ src/
components/ # Reusable components components/ # Reusable components
ui/ # Base UI components (GlassCard, GlassButton, etc.) ui/
lib/ # Utilities (supabase, analytics, revenuecat) lib/ # Utilities (supabase, analytics, revenuecat)
store/ # Zustand stores (index.ts) store/ # Zustand stores (index.ts)
theme/ # Theme constants (colors, gradients, spacing, etc.) theme/
types/ # TypeScript types types/ # TypeScript types
``` ```
## Critical Rules ## Critical Rules
### Tab Bar - NEVER CHANGE ### File Size
**NEVER set `backgroundColor: 'transparent'` on the tab bar.** - Keep files small: aim for 200-300 lines max per file.
Always use:
```typescript
backgroundColor: 'rgba(18, 18, 18, 0.85)',
```
The BlurView in `tabBarBackground` is unreliable. The solid rgba background acts as a fallback.
Location: `app/(app)/_layout.tsx` - `tabBarStyle`
### State Management ### State Management
@@ -53,24 +44,6 @@ Location: `app/(app)/_layout.tsx` - `tabBarStyle`
- `.single()` throws an error if 0 or >1 rows are returned - `.single()` throws an error if 0 or >1 rows are returned
- Check for null before performing operations - Check for null before performing operations
### UI Components
Use existing UI primitives from `src/components/ui/`:
- `GlassCard` - Glass-morphism card container
- `GlassButton` - Glass-styled button
- `GlassInput` - Glass-styled input field
- `GradientBackground` - Screen background gradient
- `ShimmerEffect` - Loading shimmer animation
### Theme Usage
Import from `src/theme/index.ts`:
```typescript
import { colors, gradients, spacing, radius, typography, shadows } from '@/theme';
```
**DO NOT hardcode colors**. Always use theme values.
### Response Handling ### Response Handling
- Responses use UPSERT with `onConflict: "user_id,question_id"` - Responses use UPSERT with `onConflict: "user_id,question_id"`
@@ -94,9 +67,7 @@ import { colors, gradients, spacing, radius, typography, shadows } from '@/theme
## Forbidden Actions ## Forbidden Actions
1. **DO NOT** change tab bar background to transparent 1. **DO NOT** use `.single()` for queries that might return 0 rows
2. **DO NOT** use `.single()` for queries that might return 0 rows 2. **DO NOT** create new stores without integrating with auth sign-out cleanup
3. **DO NOT** hardcode colors - use theme values 3. **DO NOT** bypass RevenueCat availability checks
4. **DO NOT** create new stores without integrating with auth sign-out cleanup 4. **DO NOT** add navigation logic outside of expo-router
5. **DO NOT** bypass RevenueCat availability checks
6. **DO NOT** add navigation logic outside of expo-router
+24 -422
View File
@@ -1,19 +1,18 @@
import { Tabs, useRouter, useSegments } from "expo-router"; import { Tabs, useRouter, useSegments } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState, AppStateStatus } from "react-native"; import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState } from "react-native";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore, useStreakStore } from "../../src/store"; import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore, useStreakStore } from "../../src/store";
import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme"; import { colors, radius, spacing, typography, shadows } from "../../src/theme";
import { supabase } from "../../src/lib/supabase"; import { supabase } from "../../src/lib/supabase";
import { isBiometricEnabled } from "../../src/lib/biometricAuth"; import { isBiometricEnabled } from "../../src/lib/biometricAuth";
import { checkAndRegisterPushToken } from "../../src/lib/notifications"; import { checkAndRegisterPushToken } from "../../src/lib/notifications";
import { syncBadgeCount } from "../../src/lib/badge"; import { syncBadgeCount } from "../../src/lib/badge";
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen"; import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal";
import { Events } from "../../src/lib/analytics"; import { Events } from "../../src/lib/analytics";
import type { MatchWithQuestion, AnswerType } from "../../src/types"; import { needsOnboarding } from "../../src/constants/onboarding";
import type { MatchWithQuestion } from "../../src/types";
import type { Database } from "../../src/types/supabase"; import type { Database } from "../../src/types/supabase";
type Message = Database["public"]["Tables"]["messages"]["Row"]; type Message = Database["public"]["Tables"]["messages"]["Row"];
@@ -54,157 +53,21 @@ function TabBarBackground() {
return null; return null;
} }
interface PlayTabButtonProps {
children?: React.ReactNode;
onPress?: (e: any) => void;
accessibilityState?: { selected?: boolean };
isMenuOpen?: boolean;
onToggleMenu?: () => void;
}
interface RadialMenuItem {
id: string;
icon: keyof typeof Ionicons.glyphMap;
label: string;
angle: number; // degrees from top (0 = top, -45 = left, 45 = right)
variant: 'primary' | 'gold' | 'rose' | 'muted';
}
// Brand color palette for navigation
const NAV_COLORS = {
// Primary brand colors (from logo)
primary: colors.primary,
primaryRgba: 'rgba(233, 69, 96, ',
secondary: colors.secondary,
secondaryRgba: 'rgba(155, 89, 182, ',
// Feature-specific colors
gold: colors.premium.gold,
goldRgba: 'rgba(212, 175, 55, ',
rose: colors.premium.rose,
roseRgba: 'rgba(232, 164, 174, ',
dark: '#0d0d1a',
};
const RADIAL_MENU_ITEMS: RadialMenuItem[] = [
{ id: 'dares', icon: 'flash', label: 'Dares', angle: -50, variant: 'gold' },
{ id: 'match', icon: 'flame', label: 'Match', angle: 0, variant: 'primary' },
{ id: 'quiz', icon: 'help-circle', label: 'Quiz', angle: 50, variant: 'rose' },
];
const RADIAL_DISTANCE = 100; // Distance from center button
// Store for sharing state between PlayTabButton and AppLayout
let globalMenuToggle: (() => void) | null = null;
let globalMenuOpen = false;
function PlayTabButton({ accessibilityState }: PlayTabButtonProps) {
const isSelected = accessibilityState?.selected;
const [, forceUpdate] = useState({});
// Subscribe to global state changes
useEffect(() => {
const interval = setInterval(() => {
forceUpdate({});
}, 50);
return () => clearInterval(interval);
}, []);
return (
<View style={styles.playButtonContainer}>
{/* Outer glow effect - primary brand */}
<View style={[styles.playButtonGlow, isSelected && styles.playButtonGlowActive]} />
{/* Main circle button - premium dark with primary gradient border */}
<Pressable onPress={() => globalMenuToggle?.()}>
<Animated.View style={styles.playButtonCircle}>
<View style={styles.playButtonInner}>
{/* Silk highlight at top - primary gradient */}
<LinearGradient
colors={[`${NAV_COLORS.primaryRgba}0.2)`, `${NAV_COLORS.secondaryRgba}0.1)`, 'transparent']}
style={styles.playButtonHighlight}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Ionicons
name={globalMenuOpen ? "close" : "flame"}
size={28}
color={globalMenuOpen ? colors.textSecondary : NAV_COLORS.primary}
/>
</View>
</Animated.View>
</Pressable>
</View>
);
}
export default function AppLayout() { export default function AppLayout() {
const router = useRouter(); const router = useRouter();
const segments = useSegments(); const segments = useSegments();
const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore(); const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore(); const { matches, newMatchesCount, addMatch, updateMatchUnreadCount, pendingQuestions, fetchPendingQuestions } = useMatchStore();
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore(); const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
const { fetchEnabledPacks } = usePacksStore(); const { fetchEnabledPacks } = usePacksStore();
const { initializeRevenueCat } = useSubscriptionStore(); const { initializeRevenueCat } = useSubscriptionStore();
const { fetchStreak } = useStreakStore(); const { fetchStreak } = useStreakStore();
const [matchNotification, setMatchNotification] = useState<MatchNotificationData | null>(null);
const messageToastAnim = useRef(new Animated.Value(-100)).current; const messageToastAnim = useRef(new Animated.Value(-100)).current;
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null); const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isLocked, setIsLocked] = useState(false); const [isLocked, setIsLocked] = useState(false);
const hasCheckedInitialBiometric = useRef(false); const hasCheckedInitialBiometric = useRef(false);
const wentToBackgroundAt = useRef<number | null>(null); const wentToBackgroundAt = useRef<number | null>(null);
// Radial menu state
const [isRadialMenuOpen, setIsRadialMenuOpen] = useState(false);
const radialMenuAnim = useRef(new Animated.Value(0)).current;
const toggleRadialMenu = useCallback(() => {
const toValue = isRadialMenuOpen ? 0 : 1;
setIsRadialMenuOpen(!isRadialMenuOpen);
globalMenuOpen = !isRadialMenuOpen;
Animated.spring(radialMenuAnim, {
toValue,
useNativeDriver: true,
tension: 100,
friction: 8,
}).start();
}, [isRadialMenuOpen, radialMenuAnim]);
const closeRadialMenu = useCallback(() => {
setIsRadialMenuOpen(false);
globalMenuOpen = false;
Animated.timing(radialMenuAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}).start();
}, [radialMenuAnim]);
const handleRadialMenuItemPress = useCallback((itemId: string) => {
closeRadialMenu();
// Navigate based on item
switch (itemId) {
case 'match':
router.push('/(app)/swipe');
break;
case 'dares':
router.push('/(app)/dares');
break;
case 'quiz':
router.push('/(app)/quiz');
break;
}
}, [router, closeRadialMenu]);
// Register global menu toggle for PlayTabButton
useEffect(() => {
globalMenuToggle = toggleRadialMenu;
return () => {
globalMenuToggle = null;
};
}, [toggleRadialMenu]);
// Check if we're on screens that should hide the tab bar // Check if we're on screens that should hide the tab bar
const segmentStrings = segments as string[]; const segmentStrings = segments as string[];
const isOnOnboarding = segmentStrings.includes("onboarding"); const isOnOnboarding = segmentStrings.includes("onboarding");
@@ -213,11 +76,11 @@ export default function AppLayout() {
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2; const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnSettingsSubscreen; const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnSettingsSubscreen;
// Redirect to login when not authenticated, or to onboarding if not completed // Redirect to login when not authenticated, or to onboarding if not completed/outdated
useEffect(() => { useEffect(() => {
if (!isLoading && !isAuthenticated) { if (!isLoading && !isAuthenticated) {
router.replace("/(auth)/login"); router.replace("/(auth)/login");
} else if (!isLoading && isAuthenticated && user && !user.onboarding_completed && !isOnOnboarding) { } else if (!isLoading && isAuthenticated && user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding) {
router.replace("/(app)/onboarding"); router.replace("/(app)/onboarding");
} }
}, [isLoading, isAuthenticated, user, router, isOnOnboarding]); }, [isLoading, isAuthenticated, user, router, isOnOnboarding]);
@@ -316,10 +179,17 @@ export default function AppLayout() {
} }
}, [user?.couple_id, fetchStreak]); }, [user?.couple_id, fetchStreak]);
// Sync app icon badge count whenever matches or messages change // Fetch pending questions (Your Turn) when user is in a couple
useEffect(() => { useEffect(() => {
syncBadgeCount(newMatchesCount, unreadCount); if (user?.couple_id) {
}, [newMatchesCount, unreadCount]); fetchPendingQuestions();
}
}, [user?.couple_id, fetchPendingQuestions]);
// Sync app icon badge count whenever matches, messages, or pending questions change
useEffect(() => {
syncBadgeCount(newMatchesCount + pendingQuestions.length, unreadCount);
}, [newMatchesCount, unreadCount, pendingQuestions.length]);
// Initialize RevenueCat for subscription management (iOS and Android only) // Initialize RevenueCat for subscription management (iOS and Android only)
useEffect(() => { useEffect(() => {
@@ -367,7 +237,7 @@ export default function AppLayout() {
}; };
}, [user?.id, user?.couple_id]); }, [user?.id, user?.couple_id]);
// Subscribe to realtime match notifications // Subscribe to realtime match notifications (for badge updates and analytics)
useEffect(() => { useEffect(() => {
if (!user?.couple_id) return; if (!user?.couple_id) return;
@@ -401,42 +271,12 @@ export default function AppLayout() {
Events.milestoneMatch(currentMatchCount + 1); Events.milestoneMatch(currentMatchCount + 1);
} }
// Add to store // Add to store (updates badge count via newMatchesCount)
addMatch(matchWithQuestion); addMatch(matchWithQuestion);
Events.matchCreated(matchWithQuestion.match_type || "unknown"); Events.matchCreated(matchWithQuestion.match_type || "unknown");
// Fetch individual responses to show what each person said // Note: No modal popup - user will see badge on matches tab
const { data: responses } = await supabase // Confetti animation is shown inline on swipe screen when user creates match
.from("responses")
.select("user_id, answer")
.eq("question_id", matchWithQuestion.question_id)
.eq("couple_id", user.couple_id);
let userResponse: AnswerType | undefined;
let partnerResponse: AnswerType | undefined;
if (responses && responses.length >= 2) {
for (const r of responses) {
if (r.user_id === user.id) {
userResponse = r.answer as AnswerType;
} else {
partnerResponse = r.answer as AnswerType;
}
}
}
// Get partner name from auth store
const { partner } = useAuthStore.getState();
// Show notification with response data
setMatchNotification({
id: matchWithQuestion.id,
match_type: matchWithQuestion.match_type,
question: matchWithQuestion.question,
userResponse,
partnerResponse,
partnerName: partner?.name,
});
} }
} }
) )
@@ -561,18 +401,6 @@ export default function AppLayout() {
}; };
}, [lastMessage]); }, [lastMessage]);
const dismissNotification = useCallback(() => {
setMatchNotification(null);
}, []);
const handleMatchChat = useCallback(() => {
if (matchNotification) {
const matchId = matchNotification.id;
setMatchNotification(null);
router.push(`/(app)/chat/${matchId}`);
}
}, [matchNotification, router]);
const dismissMessageToast = useCallback(() => { const dismissMessageToast = useCallback(() => {
Animated.timing(messageToastAnim, { Animated.timing(messageToastAnim, {
toValue: -100, toValue: -100,
@@ -591,7 +419,7 @@ export default function AppLayout() {
}, [lastMessage, dismissMessageToast, router]); }, [lastMessage, dismissMessageToast, router]);
// Show loading state while checking authentication or redirecting to onboarding // Show loading state while checking authentication or redirecting to onboarding
if (isLoading || !isAuthenticated || (user && !user.onboarding_completed && !isOnOnboarding)) { if (isLoading || !isAuthenticated || (user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding)) {
return ( return (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
@@ -633,20 +461,10 @@ export default function AppLayout() {
), ),
}} }}
/> />
<Tabs.Screen
name="packs"
options={{
title: "Packs",
tabBarIcon: ({ color, size }) => (
<Ionicons name="layers" size={size} color={color} />
),
}}
/>
<Tabs.Screen <Tabs.Screen
name="swipe" name="swipe"
options={{ options={{
title: "", href: null, // Hide from tab bar - accessed from home screen
tabBarButton: (props) => <PlayTabButton {...props} />,
}} }}
/> />
<Tabs.Screen <Tabs.Screen
@@ -656,7 +474,7 @@ export default function AppLayout() {
tabBarIcon: ({ color, size }) => ( tabBarIcon: ({ color, size }) => (
<Ionicons name="heart" size={size} color={color} /> <Ionicons name="heart" size={size} color={color} />
), ),
tabBarBadge: (newMatchesCount + unreadCount) > 0 ? (newMatchesCount + unreadCount) : undefined, tabBarBadge: (newMatchesCount + unreadCount + pendingQuestions.length) > 0 ? (newMatchesCount + unreadCount + pendingQuestions.length) : undefined,
tabBarBadgeStyle: { tabBarBadgeStyle: {
backgroundColor: colors.premium.rose, backgroundColor: colors.premium.rose,
fontSize: 9, fontSize: 9,
@@ -720,128 +538,6 @@ export default function AppLayout() {
/> />
</Tabs> </Tabs>
{/* Radial Menu Overlay */}
{isRadialMenuOpen && (
<Pressable
style={radialStyles.overlay}
onPress={closeRadialMenu}
/>
)}
{/* Radial Menu Items - positioned above the center button */}
<Animated.View
style={[
radialStyles.menuContainer,
{ opacity: radialMenuAnim },
]}
pointerEvents={isRadialMenuOpen ? 'auto' : 'none'}
>
{RADIAL_MENU_ITEMS.map((item) => {
// Convert angle to radians and calculate position
const angleRad = (item.angle - 90) * (Math.PI / 180); // -90 to make 0 = top
const translateX = Math.cos(angleRad) * RADIAL_DISTANCE;
const translateY = Math.sin(angleRad) * RADIAL_DISTANCE;
const itemScale = radialMenuAnim.interpolate({
inputRange: [0, 1],
outputRange: [0.3, 1],
});
const itemTranslateX = radialMenuAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, translateX],
});
const itemTranslateY = radialMenuAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, translateY],
});
// Brand and feature variant colors
const getVariantStyles = () => {
switch (item.variant) {
case 'primary':
return {
bg: `${NAV_COLORS.primaryRgba}0.15)`,
border: `${NAV_COLORS.primaryRgba}0.35)`,
icon: NAV_COLORS.primary,
label: NAV_COLORS.primary,
};
case 'gold':
return {
bg: `${NAV_COLORS.goldRgba}0.15)`,
border: `${NAV_COLORS.goldRgba}0.35)`,
icon: NAV_COLORS.gold,
label: NAV_COLORS.gold,
};
case 'rose':
return {
bg: `${NAV_COLORS.roseRgba}0.15)`,
border: `${NAV_COLORS.roseRgba}0.35)`,
icon: NAV_COLORS.rose,
label: NAV_COLORS.rose,
};
case 'muted':
default:
return {
bg: 'rgba(255, 255, 255, 0.08)',
border: 'rgba(255, 255, 255, 0.15)',
icon: colors.textSecondary,
label: colors.textSecondary,
};
}
};
const variantStyles = getVariantStyles();
return (
<Animated.View
key={item.id}
style={[
radialStyles.menuItem,
{
transform: [
{ translateX: itemTranslateX },
{ translateY: itemTranslateY },
{ scale: itemScale },
],
},
]}
>
<Pressable
onPress={() => handleRadialMenuItemPress(item.id)}
style={radialStyles.menuItemButton}
>
<View style={[
radialStyles.menuItemCircle,
{
backgroundColor: variantStyles.bg,
borderColor: variantStyles.border,
},
]}>
<Ionicons
name={item.icon}
size={22}
color={variantStyles.icon}
/>
</View>
<Text style={[radialStyles.menuItemLabel, { color: variantStyles.label }]}>
{item.label}
</Text>
</Pressable>
</Animated.View>
);
})}
</Animated.View>
{/* Match Notification Modal - Premium styling with response details */}
<MatchNotificationModal
visible={!!matchNotification}
match={matchNotification}
onDismiss={dismissNotification}
onChat={handleMatchChat}
/>
{/* Message Toast Notification */} {/* Message Toast Notification */}
{lastMessage && ( {lastMessage && (
<Animated.View <Animated.View
@@ -923,98 +619,4 @@ const styles = StyleSheet.create({
color: colors.textSecondary, color: colors.textSecondary,
marginTop: 2, marginTop: 2,
}, },
// Play button styles - Premium boutique
playButtonContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
marginTop: -28,
},
playButtonGlow: {
position: 'absolute',
width: 76,
height: 76,
borderRadius: 38,
backgroundColor: colors.primaryGlow,
opacity: 0.4,
},
playButtonGlowActive: {
opacity: 0.7,
...shadows.glow(colors.primary),
},
playButtonCircle: {
width: 64,
height: 64,
borderRadius: 32,
overflow: 'hidden',
borderWidth: 1.5,
borderColor: 'rgba(233, 69, 96, 0.4)', // primary brand
...shadows.lg,
},
playButtonInner: {
flex: 1,
backgroundColor: '#0d0d1a',
alignItems: 'center',
justifyContent: 'center',
},
playButtonHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '50%',
},
playButtonGradient: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
// Radial menu styles - Premium boutique
const TAB_BAR_HEIGHT = Platform.OS === 'ios' ? 88 : 64;
const radialStyles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(13, 13, 26, 0.85)',
zIndex: 998,
},
menuContainer: {
position: 'absolute',
bottom: TAB_BAR_HEIGHT + 30, // Position above tab bar, centered on button
left: 0,
right: 0,
alignItems: 'center',
zIndex: 999,
},
menuItem: {
position: 'absolute',
alignItems: 'center',
},
menuItemButton: {
alignItems: 'center',
},
menuItemCircle: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(22, 33, 62, 0.9)',
justifyContent: 'center',
alignItems: 'center',
...shadows.md,
borderWidth: 1.5,
borderColor: 'rgba(255, 255, 255, 0.15)',
},
menuItemLabel: {
...typography.caption2,
color: colors.text,
marginTop: 8,
fontWeight: '500',
letterSpacing: 1,
textTransform: 'uppercase',
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
}); });
+23 -23
View File
@@ -163,14 +163,35 @@ const styles = StyleSheet.create({
opacity: 0.6, opacity: 0.6,
}, },
badge: { badge: {
backgroundColor: 'rgba(212, 175, 55, 0.1)', backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
borderRadius: radius.full, borderRadius: radius.full,
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)', borderColor: colors.border,
marginBottom: spacing.xl, marginBottom: spacing.xl,
}, },
interestButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.sm,
},
interestButtonActive: {
backgroundColor: colors.background,
borderColor: colors.primary,
},
interestButtonText: {
...typography.subhead,
fontWeight: '600',
color: ACCENT_COLOR,
},
badgeText: { badgeText: {
...typography.caption2, ...typography.caption2,
fontWeight: '600', fontWeight: '600',
@@ -208,25 +229,4 @@ const styles = StyleSheet.create({
marginTop: spacing.xl, marginTop: spacing.xl,
minHeight: 44, minHeight: 44,
}, },
interestButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(212, 175, 55, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
gap: spacing.sm,
},
interestButtonActive: {
backgroundColor: 'rgba(212, 175, 55, 0.15)',
borderColor: 'rgba(212, 175, 55, 0.3)',
},
interestButtonText: {
...typography.subhead,
fontWeight: '600',
color: ACCENT_COLOR,
},
}); });
+28 -3
View File
@@ -3,6 +3,7 @@ import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } fro
import { useFocusEffect } from 'expo-router'; import { useFocusEffect } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated'; import Animated, { FadeInDown } from 'react-native-reanimated';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { useAuthStore, usePacksStore } from '../../src/store'; import { useAuthStore, usePacksStore } from '../../src/store';
import { GradientBackground } from '../../src/components/ui'; import { GradientBackground } from '../../src/components/ui';
import { Paywall } from '../../src/components/paywall'; import { Paywall } from '../../src/components/paywall';
@@ -69,6 +70,7 @@ export default function DiscoveryScreen() {
const { packs, categories, fetchPacks, getPackProgress } = usePacksStore(); const { packs, categories, fetchPacks, getPackProgress } = usePacksStore();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const [showPaywall, setShowPaywall] = useState(false); const [showPaywall, setShowPaywall] = useState(false);
const [headerHeight, setHeaderHeight] = useState(130);
const isPremiumUser = user?.is_premium ?? false; const isPremiumUser = user?.is_premium ?? false;
const isWideScreen = width > MAX_CONTENT_WIDTH; const isWideScreen = width > MAX_CONTENT_WIDTH;
@@ -91,18 +93,25 @@ export default function DiscoveryScreen() {
return ( return (
<GradientBackground> <GradientBackground>
<View
style={[styles.stickyHeader, isWideScreen && styles.stickyHeaderWide]}
onLayout={(e) => setHeaderHeight(e.nativeEvent.layout.height)}
>
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(14, 14, 17, 0.85)' }]} />
<CompactHeader user={user} partner={partner} couple={couple} />
</View>
<ScrollView <ScrollView
style={styles.container} style={styles.container}
contentContainerStyle={[ contentContainerStyle={[
styles.contentContainer, styles.contentContainer,
isWideScreen && styles.contentContainerWide, isWideScreen && styles.contentContainerWide,
{ paddingTop: headerHeight },
]} ]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}> <View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
{/* Compact Header */}
<CompactHeader user={user} partner={partner} couple={couple} />
{/* Content Rows by Category */} {/* Content Rows by Category */}
{packsByCategory.map((group, index) => ( {packsByCategory.map((group, index) => (
<ContentRow <ContentRow
@@ -150,6 +159,7 @@ export default function DiscoveryScreen() {
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
@@ -197,4 +207,19 @@ const styles = StyleSheet.create({
bottomSpacer: { bottomSpacer: {
height: spacing.lg, height: spacing.lg,
}, },
stickyHeader: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
overflow: 'hidden', // Ensures blur doesn't bleed if we rounded corners (optional)
},
stickyHeaderWide: {
left: '50%',
right: 'auto',
width: MAX_CONTENT_WIDTH,
transform: [{ translateX: -MAX_CONTENT_WIDTH / 2 }],
},
}); });
File diff suppressed because it is too large. Load diff
+56 -45
View File
@@ -48,7 +48,7 @@ type GroupByOption = "pack" | "date" | "answer";
export default function MyAnswersScreen() { export default function MyAnswersScreen() {
const { responses, isLoading, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore(); const { responses, isLoading, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
const { user } = useAuthStore(); const { user, partner } = useAuthStore();
const router = useRouter(); const router = useRouter();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
@@ -152,6 +152,9 @@ export default function MyAnswersScreen() {
index={index} index={index}
onEditPress={() => handleEditPress(item)} onEditPress={() => handleEditPress(item)}
onChatPress={item.match_id ? () => router.push(`/chat/${item.match_id}`) : undefined} onChatPress={item.match_id ? () => router.push(`/chat/${item.match_id}`) : undefined}
viewerId={user?.id}
viewerName={user?.name}
partnerName={partner?.name}
/> />
); );
@@ -210,7 +213,7 @@ export default function MyAnswersScreen() {
return ( return (
<GradientBackground> <GradientBackground>
{/* Ambient Orbs */} {/* Ambient Orbs - Commented out for flat look
<Animated.View <Animated.View
style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]}
pointerEvents="none" pointerEvents="none"
@@ -233,16 +236,13 @@ export default function MyAnswersScreen() {
end={{ x: 0, y: 0 }} end={{ x: 0, y: 0 }}
/> />
</Animated.View> </Animated.View>
*/}
<View style={styles.container}> <View style={styles.container}>
{/* Fixed Nav Bar */} {/* Fixed Nav Bar */}
<View style={styles.navBar}> <View style={styles.navBar}>
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}> <Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
{Platform.OS === "ios" ? ( <View style={[StyleSheet.absoluteFill, { backgroundColor: colors.background }]} />
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
) : (
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
)}
</Animated.View> </Animated.View>
<TouchableOpacity style={styles.navBarBackButton} onPress={handleBack}> <TouchableOpacity style={styles.navBarBackButton} onPress={handleBack}>
@@ -322,7 +322,7 @@ export default function MyAnswersScreen() {
come back here to review and change your answers anytime. come back here to review and change your answers anytime.
</Text> </Text>
<GlassButton <GlassButton
onPress={() => router.push("/(app)/swipe")} onPress={() => router.push("/")}
style={{ marginTop: spacing.lg }} style={{ marginTop: spacing.lg }}
> >
Start Swiping Start Swiping
@@ -390,7 +390,8 @@ const styles = StyleSheet.create({
navBarBackground: { navBarBackground: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: 'rgba(212, 175, 55, 0.15)', // Gold tint borderBottomColor: colors.border,
backgroundColor: colors.background,
overflow: "hidden", overflow: "hidden",
}, },
navBarBackButton: { navBarBackButton: {
@@ -401,12 +402,52 @@ const styles = StyleSheet.create({
width: 36, width: 36,
height: 36, height: 36,
borderRadius: 18, borderRadius: 18,
backgroundColor: "rgba(255, 255, 255, 0.08)", backgroundColor: colors.backgroundLight,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
borderWidth: 1, borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)", borderColor: colors.border,
}, },
// ...
countBadgePremium: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.xs,
},
// ...
groupByButton: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
backgroundColor: colors.backgroundLight,
borderWidth: 1,
borderColor: colors.border,
},
groupByButtonActive: {
backgroundColor: colors.background,
borderColor: colors.primary,
},
// ...
emptyIcon: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: colors.backgroundLight,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
borderWidth: 1,
borderColor: colors.border,
},
navBarTitle: { navBarTitle: {
...typography.headline, ...typography.headline,
color: colors.text, color: colors.text,
@@ -439,17 +480,7 @@ const styles = StyleSheet.create({
color: colors.text, color: colors.text,
textAlign: "center", textAlign: "center",
}, },
countBadgePremium: {
flexDirection: "row",
alignItems: "center",
backgroundColor: `${ACCENT_RGBA}0.1)`,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
gap: spacing.xs,
},
countTextPremium: { countTextPremium: {
...typography.caption2, ...typography.caption2,
fontWeight: "600", fontWeight: "600",
@@ -463,20 +494,8 @@ const styles = StyleSheet.create({
gap: spacing.sm, gap: spacing.sm,
marginTop: spacing.md, marginTop: spacing.md,
}, },
groupByButton: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
},
groupByButtonActive: {
backgroundColor: `${ACCENT_RGBA}0.15)`,
borderColor: `${ACCENT_RGBA}0.3)`,
},
groupByText: { groupByText: {
...typography.caption1, ...typography.caption1,
color: colors.textSecondary, color: colors.textSecondary,
@@ -518,15 +537,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.xxl, paddingVertical: spacing.xxl,
}, },
emptyIcon: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: `${ACCENT_RGBA}0.1)`,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
},
emptyTitle: { emptyTitle: {
...typography.title2, ...typography.title2,
color: colors.text, color: colors.text,
+127 -74
View File
@@ -25,13 +25,16 @@ import { GradientBackground } from '../../src/components/ui/GradientBackground';
import { GlassCard } from '../../src/components/ui/GlassCard'; import { GlassCard } from '../../src/components/ui/GlassCard';
import { GlassButton } from '../../src/components/ui/GlassButton'; import { GlassButton } from '../../src/components/ui/GlassButton';
import { GlassToggle } from '../../src/components/ui/GlassToggle'; import { GlassToggle } from '../../src/components/ui/GlassToggle';
import { Paywall } from '../../src/components/paywall';
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme'; import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
import { useAuthStore } from '../../src/store'; import { useAuthStore } from '../../src/store';
import { supabase } from '../../src/lib/supabase'; import { supabase } from '../../src/lib/supabase';
import { getProfileError } from '../../src/lib/errors'; import { getProfileError } from '../../src/lib/errors';
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications'; import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
import { Events } from '../../src/lib/analytics'; import { Events } from '../../src/lib/analytics';
import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen';
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker'; import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
import { REQUIRED_ONBOARDING_VERSION, ONBOARDING_OFFERING_ID } from '../../src/constants/onboarding';
import type { Gender } from '../../src/types'; import type { Gender } from '../../src/types';
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [ const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
@@ -69,7 +72,9 @@ export default function OnboardingScreen() {
const [hideNsfw, setHideNsfw] = useState(user?.hide_nsfw ?? false); const [hideNsfw, setHideNsfw] = useState(user?.hide_nsfw ?? false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showPaywall, setShowPaywall] = useState(false);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const paywallNavigationRef = useRef(false);
const { const {
avatarUri, avatarUri,
@@ -187,6 +192,7 @@ export default function OnboardingScreen() {
show_explicit_content: showExplicitContent, show_explicit_content: showExplicitContent,
hide_nsfw: hideNsfw, hide_nsfw: hideNsfw,
onboarding_completed: true, onboarding_completed: true,
onboarding_version: REQUIRED_ONBOARDING_VERSION,
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}; };
@@ -210,6 +216,15 @@ export default function OnboardingScreen() {
console.log('[Onboarding] User fetched, navigating to home...'); console.log('[Onboarding] User fetched, navigating to home...');
Events.profileUpdated(updatedFields); Events.profileUpdated(updatedFields);
Events.onboardingComplete(); Events.onboardingComplete();
const currentUser = useAuthStore.getState().user;
if (currentUser?.id && !currentUser.is_premium) {
const hasSeenPaywall = await hasSeenOnboardingPaywall(currentUser.id);
if (!hasSeenPaywall) {
paywallNavigationRef.current = true;
setShowPaywall(true);
return;
}
}
router.replace('/'); router.replace('/');
} catch (err: any) { } catch (err: any) {
console.error('Onboarding error:', err); console.error('Onboarding error:', err);
@@ -219,6 +234,18 @@ export default function OnboardingScreen() {
} }
}; };
const handlePaywallClose = () => {
setShowPaywall(false);
const currentUser = useAuthStore.getState().user;
if (currentUser?.id) {
void markOnboardingPaywallSeen(currentUser.id);
}
if (paywallNavigationRef.current) {
paywallNavigationRef.current = false;
router.replace('/');
}
};
const renderStage = () => { const renderStage = () => {
switch (stage) { switch (stage) {
case 'avatar': case 'avatar':
@@ -248,12 +275,11 @@ export default function OnboardingScreen() {
{avatarUri ? ( {avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.avatarImage} /> <Image source={{ uri: avatarUri }} style={styles.avatarImage} />
) : ( ) : (
<LinearGradient <View
colors={[colors.glass.background, colors.glass.backgroundLight]}
style={styles.avatarPlaceholder} style={styles.avatarPlaceholder}
> >
<Ionicons name="camera" size={40} color={colors.textSecondary} /> <Ionicons name="camera" size={40} color={colors.textSecondary} />
</LinearGradient> </View>
)} )}
<View style={styles.avatarBadge}> <View style={styles.avatarBadge}>
<Ionicons name={avatarUri ? "pencil" : "add"} size={16} color={colors.text} /> <Ionicons name={avatarUri ? "pencil" : "add"} size={16} color={colors.text} />
@@ -662,6 +688,12 @@ export default function OnboardingScreen() {
{renderStage()} {renderStage()}
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
<Paywall
visible={showPaywall}
onClose={handlePaywallClose}
offeringId={ONBOARDING_OFFERING_ID}
/>
</GradientBackground> </GradientBackground>
); );
} }
@@ -685,9 +717,92 @@ const styles = StyleSheet.create({
width: 40, width: 40,
height: 40, height: 40,
borderRadius: 20, borderRadius: 20,
backgroundColor: colors.glass.background, backgroundColor: colors.backgroundLight,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderWidth: 1,
borderColor: colors.border,
},
// ...
progressDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.border,
},
// ...
avatarPlaceholder: {
width: 120,
height: 120,
borderRadius: 60,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.border,
borderStyle: 'dashed',
backgroundColor: colors.backgroundLight,
},
// ...
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background, // Flat background
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: spacing.md,
},
// ...
genderOption: {
width: '48%',
alignItems: 'center',
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.border,
paddingVertical: spacing.lg,
paddingHorizontal: spacing.md,
gap: spacing.sm,
},
// ...
purposeOption: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.border,
padding: spacing.md,
},
// ...
purposeIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.background,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
// ...
radioOuter: {
width: 24,
height: 24,
borderRadius: 12,
borderWidth: 2,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
},
// ...
notificationFeature: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}, },
backButtonPlaceholder: { backButtonPlaceholder: {
width: 40, width: 40,
@@ -697,12 +812,7 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
gap: spacing.sm, gap: spacing.sm,
}, },
progressDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.glass.border,
},
progressDotActive: { progressDotActive: {
backgroundColor: colors.primary, backgroundColor: colors.primary,
width: 24, width: 24,
@@ -747,16 +857,7 @@ const styles = StyleSheet.create({
borderWidth: 3, borderWidth: 3,
borderColor: colors.primary, borderColor: colors.primary,
}, },
avatarPlaceholder: {
width: 120,
height: 120,
borderRadius: 60,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.glass.border,
borderStyle: 'dashed',
},
avatarBadge: { avatarBadge: {
position: 'absolute', position: 'absolute',
bottom: 4, bottom: 4,
@@ -800,15 +901,7 @@ const styles = StyleSheet.create({
color: colors.text, color: colors.text,
marginBottom: spacing.md, marginBottom: spacing.md,
}, },
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
paddingHorizontal: spacing.md,
},
inputIcon: { inputIcon: {
marginRight: spacing.sm, marginRight: spacing.sm,
}, },
@@ -823,17 +916,7 @@ const styles = StyleSheet.create({
flexWrap: 'wrap', flexWrap: 'wrap',
gap: spacing.sm, gap: spacing.sm,
}, },
genderOption: {
width: '48%',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.glass.border,
paddingVertical: spacing.lg,
paddingHorizontal: spacing.md,
gap: spacing.sm,
},
genderOptionSelected: { genderOptionSelected: {
borderColor: colors.primary, borderColor: colors.primary,
backgroundColor: colors.primaryLight, backgroundColor: colors.primaryLight,
@@ -850,28 +933,12 @@ const styles = StyleSheet.create({
purposeList: { purposeList: {
gap: spacing.sm, gap: spacing.sm,
}, },
purposeOption: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.glass.border,
padding: spacing.md,
},
purposeOptionSelected: { purposeOptionSelected: {
borderColor: colors.primary, borderColor: colors.primary,
backgroundColor: colors.primaryLight, backgroundColor: colors.primaryLight,
}, },
purposeIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.glass.background,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
},
purposeIconSelected: { purposeIconSelected: {
backgroundColor: colors.primaryLight, backgroundColor: colors.primaryLight,
}, },
@@ -921,15 +988,7 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
marginTop: spacing.lg, marginTop: spacing.lg,
}, },
radioOuter: {
width: 24,
height: 24,
borderRadius: 12,
borderWidth: 2,
borderColor: colors.glass.border,
alignItems: 'center',
justifyContent: 'center',
},
radioOuterSelected: { radioOuterSelected: {
borderColor: colors.primary, borderColor: colors.primary,
}, },
@@ -956,13 +1015,7 @@ const styles = StyleSheet.create({
footer: { footer: {
marginTop: 'auto', marginTop: 'auto',
}, },
notificationFeature: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.glass.border,
},
notificationFeatureLast: { notificationFeatureLast: {
borderBottomWidth: 0, borderBottomWidth: 0,
}, },
-15
View File
@@ -1,15 +0,0 @@
import { Stack } from "expo-router";
import { colors } from "../../../src/theme";
export default function PacksLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
animation: "fade",
animationDuration: 150,
}}
/>
);
}
-298
View File
@@ -1,298 +0,0 @@
import { View, Text, StyleSheet, Pressable, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useEffect } from "react";
import Animated, { FadeInDown } from "react-native-reanimated";
import { LinearGradient } from "expo-linear-gradient";
import { GradientBackground } from "../../../src/components/ui";
import { usePacksStore } from "../../../src/store";
import { colors, spacing, radius, typography, shadows } from "../../../src/theme";
interface PackTypeCard {
id: string;
title: string;
description: string;
icon: keyof typeof Ionicons.glyphMap;
color: string;
colorRgba: string;
gradient: [string, string];
comingSoon: boolean;
route?: string;
}
const PACK_TYPES: PackTypeCard[] = [
{
id: "questions",
title: "Question Packs",
description: "Explore intimate questions together and discover what you both want",
icon: "layers",
color: colors.primary,
colorRgba: "rgba(233, 69, 96, ",
gradient: ["rgba(233, 69, 96, 0.25)", "rgba(155, 89, 182, 0.25)"],
comingSoon: false,
route: "/(app)/packs/questions",
},
{
id: "dares",
title: "Dare Packs",
description: "Spice things up with exciting dares for adventurous couples",
icon: "flash",
color: colors.premium.gold,
colorRgba: "rgba(212, 175, 55, ",
gradient: ["rgba(212, 175, 55, 0.25)", "rgba(184, 134, 11, 0.25)"],
comingSoon: true,
route: "/(app)/dares",
},
{
id: "quiz",
title: "Quiz Packs",
description: "Test how well you know each other with fun couple quizzes",
icon: "help-circle",
color: colors.premium.rose,
colorRgba: "rgba(232, 164, 174, ",
gradient: ["rgba(232, 164, 174, 0.25)", "rgba(212, 145, 155, 0.25)"],
comingSoon: true,
route: "/(app)/quiz",
},
];
export default function PackTypeSelection() {
const { fetchPacks } = usePacksStore();
// Prefetch packs data so it's ready when navigating to questions
useEffect(() => {
fetchPacks();
}, []);
const handlePackTypePress = (packType: PackTypeCard) => {
if (packType.route) {
router.push(packType.route as any);
}
};
return (
<GradientBackground>
<View style={styles.container}>
{/* Header */}
<Animated.View
entering={FadeInDown.duration(600).springify()}
style={styles.header}
>
<Text style={styles.label}>EXPLORE</Text>
<Text style={styles.title}>Content Packs</Text>
{/* Decorative Separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<Text style={styles.subtitle}>
Choose a pack type to explore
</Text>
</Animated.View>
{/* Pack Type Cards */}
<View style={styles.cardsContainer}>
{PACK_TYPES.map((packType, index) => (
<Animated.View
key={packType.id}
entering={FadeInDown.delay(100 + index * 100).duration(500).springify()}
>
<Pressable
onPress={() => handlePackTypePress(packType)}
style={({ pressed }) => [
styles.card,
pressed && !packType.comingSoon && styles.cardPressed,
packType.comingSoon && styles.cardDisabled,
]}
>
<LinearGradient
colors={packType.gradient as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.cardGradient}
>
{/* Icon Circle */}
<View
style={[
styles.iconCircle,
{
backgroundColor: `${packType.colorRgba}0.15)`,
borderColor: `${packType.colorRgba}0.3)`,
},
]}
>
<Ionicons
name={packType.icon}
size={32}
color={packType.comingSoon ? colors.textTertiary : packType.color}
/>
</View>
{/* Content */}
<View style={styles.cardContent}>
<View style={styles.cardTitleRow}>
<Text
style={[
styles.cardTitle,
packType.comingSoon && styles.cardTitleDisabled,
]}
>
{packType.title}
</Text>
{packType.comingSoon && (
<View style={styles.comingSoonBadge}>
<Text style={styles.comingSoonText}>Coming Soon</Text>
</View>
)}
</View>
<Text
style={[
styles.cardDescription,
packType.comingSoon && styles.cardDescriptionDisabled,
]}
>
{packType.description}
</Text>
</View>
{/* Arrow */}
{!packType.comingSoon && (
<Ionicons
name="chevron-forward"
size={24}
color={colors.textSecondary}
/>
)}
</LinearGradient>
</Pressable>
</Animated.View>
))}
</View>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
alignItems: "center",
},
label: {
...typography.caption1,
fontWeight: "600",
letterSpacing: 3,
color: colors.primary,
textAlign: "center",
marginBottom: spacing.xs,
},
title: {
...typography.title1,
color: colors.text,
textAlign: "center",
},
separator: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginVertical: spacing.md,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: "rgba(233, 69, 96, 0.3)",
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.primary,
transform: [{ rotate: "45deg" }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
},
cardsContainer: {
paddingHorizontal: spacing.lg,
gap: spacing.md,
},
card: {
borderRadius: radius.lg,
overflow: "hidden",
borderWidth: 1,
borderColor: colors.glass.border,
...shadows.md,
},
cardPressed: {
opacity: 0.9,
transform: [{ scale: 0.98 }],
},
cardDisabled: {
opacity: 0.6,
},
cardGradient: {
flexDirection: "row",
alignItems: "center",
padding: spacing.lg,
gap: spacing.md,
},
iconCircle: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
},
cardContent: {
flex: 1,
},
cardTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
marginBottom: spacing.xs,
},
cardTitle: {
...typography.title3,
color: colors.text,
},
cardTitleDisabled: {
color: colors.textSecondary,
},
cardDescription: {
...typography.subhead,
color: colors.textSecondary,
lineHeight: 20,
},
cardDescriptionDisabled: {
color: colors.textTertiary,
},
comingSoonBadge: {
backgroundColor: "rgba(255, 255, 255, 0.1)",
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.full,
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.15)",
},
comingSoonText: {
...typography.caption2,
color: colors.textTertiary,
fontWeight: "500",
textTransform: "uppercase",
letterSpacing: 0.5,
},
});
-864
View File
@@ -1,864 +0,0 @@
import { View, Text, StyleSheet, RefreshControl, Platform, Alert, Pressable } from "react-native";
import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../../src/store";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useMemo, useState, useCallback } from "react";
import Animated, {
FadeIn,
FadeOut,
useAnimatedStyle,
useSharedValue,
withTiming,
useAnimatedScrollHandler,
interpolate,
Extrapolation,
} from "react-native-reanimated";
import { BlurView } from "expo-blur";
import { GradientBackground, GlassToggle } from "../../../src/components/ui";
import { Paywall } from "../../../src/components/paywall";
import { PackTeaser } from "../../../src/components/PackTeaser";
import { colors, spacing, radius, typography } from "../../../src/theme";
import { getPackIconName } from "../../../src/lib/packIcons";
import type { QuestionPack, Category } from "../../../src/types";
const HEADER_MAX_HEIGHT = 180;
const HEADER_MIN_HEIGHT = 100;
const HEADER_SCROLL_DISTANCE = HEADER_MAX_HEIGHT - HEADER_MIN_HEIGHT;
const INTENSITY_LABELS: Record<number, string> = {
1: "Light",
2: "Mild",
3: "Moderate",
4: "Spicy",
5: "Intense",
};
const clampIntensity = (value: number) => Math.min(5, Math.max(1, Math.round(value)));
const isValidIntensity = (value?: number | null): value is number =>
typeof value === "number" && value >= 1 && value <= 5;
const getIntensityDisplay = (pack: QuestionPack) => {
const min = isValidIntensity(pack.min_intensity) ? pack.min_intensity : null;
const max = isValidIntensity(pack.max_intensity) ? pack.max_intensity : null;
const avg = typeof pack.avg_intensity === "number" ? clampIntensity(pack.avg_intensity) : null;
const fallback = avg ?? (pack.is_explicit ? 5 : 2);
const minCount = min ?? max ?? fallback;
const maxCount = max ?? min ?? fallback;
const label = minCount === maxCount
? INTENSITY_LABELS[minCount]
: `${INTENSITY_LABELS[minCount]} - ${INTENSITY_LABELS[maxCount]}`;
return { minCount, maxCount, label };
};
interface PackRowProps {
pack: QuestionPack;
isEnabled: boolean;
isPremiumLocked: boolean;
isExpanded: boolean;
onToggle: () => void;
onPress: () => void;
onInfoPress: () => void;
}
function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPress, onInfoPress }: PackRowProps) {
const questionCount = pack.questions?.[0]?.count || 0;
const intensityDisplay = getIntensityDisplay(pack);
return (
<Pressable onPress={onPress} style={styles.packRow}>
<View style={styles.packRowMain}>
<View style={[styles.packIcon, isEnabled && styles.packIconEnabled]}>
<Ionicons
name={getPackIconName(pack.icon)}
size={20}
color={isEnabled ? colors.primary : colors.textSecondary}
/>
</View>
<View style={styles.packContent}>
<View style={styles.packTitleRow}>
<Text style={styles.packName} numberOfLines={1}>{pack.name}</Text>
{pack.is_premium && (
<View style={[styles.premiumBadge, isPremiumLocked && styles.premiumBadgeLocked]}>
<Ionicons
name={isPremiumLocked ? "lock-closed" : "sparkles"}
size={12}
color={colors.premium.gold}
/>
<Text style={styles.premiumBadgeText}>PRO</Text>
</View>
)}
</View>
<Text style={styles.packMeta}>
{questionCount} {questionCount === 1 ? "question" : "questions"}
{isEnabled && <Text style={styles.packMetaEnabled}> · Enabled</Text>}
</Text>
<View style={styles.packIntensityRow}>
<View style={styles.packIntensityFlames}>
{Array.from({ length: 5 }).map((_, index) => {
const level = index + 1;
const isInRange = level >= intensityDisplay.minCount && level <= intensityDisplay.maxCount;
return (
<Ionicons
key={`intensity-${pack.id}-${index}`}
name={isInRange ? "flame" : "flame-outline"}
size={12}
color={isInRange ? colors.primary : colors.textTertiary}
/>
);
})}
</View>
<Text style={styles.packIntensityText}>{intensityDisplay.label}</Text>
</View>
</View>
<Ionicons
name={isExpanded ? "chevron-up" : "chevron-down"}
size={20}
color={colors.textTertiary}
/>
</View>
{isExpanded && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={styles.packExpanded}
>
{pack.description && (
<Text style={styles.packDescription}>{pack.description}</Text>
)}
<View style={styles.packActions}>
<Pressable style={styles.packInfoButton} onPress={onInfoPress}>
<Ionicons name="eye-outline" size={18} color={colors.textSecondary} />
<Text style={styles.packInfoButtonText}>Preview</Text>
</Pressable>
<Pressable
style={[
styles.packToggleButton,
isEnabled && styles.packToggleButtonEnabled,
isPremiumLocked && styles.packToggleButtonLocked,
]}
onPress={onToggle}
>
{isPremiumLocked ? (
<>
<Ionicons name="lock-closed" size={16} color={colors.premium.gold} />
<Text style={[styles.packToggleButtonText, styles.packToggleButtonTextLocked]}>
Unlock
</Text>
</>
) : isEnabled ? (
<>
<Ionicons name="checkmark" size={16} color={colors.primary} />
<Text style={[styles.packToggleButtonText, styles.packToggleButtonTextEnabled]}>
Enabled
</Text>
</>
) : (
<>
<Ionicons name="add" size={16} color={colors.text} />
<Text style={styles.packToggleButtonText}>Enable</Text>
</>
)}
</Pressable>
</View>
</Animated.View>
)}
</Pressable>
);
}
interface CategorySectionProps {
category: Category;
packs: QuestionPack[];
enabledPackIds: string[];
hasPremiumAccess: boolean;
expandedPackId: string | null;
onPackPress: (packId: string) => void;
onPackToggle: (pack: QuestionPack) => void;
onPackInfo: (pack: QuestionPack) => void;
}
function CategorySection({
category,
packs,
enabledPackIds,
hasPremiumAccess,
expandedPackId,
onPackPress,
onPackToggle,
onPackInfo,
}: CategorySectionProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const enabledCount = packs.filter(p => enabledPackIds.includes(p.id)).length;
const rotateAnim = useSharedValue(0);
const chevronStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotateAnim.value}deg` }],
}));
const handleToggleCollapse = () => {
rotateAnim.value = withTiming(isCollapsed ? 0 : -90, { duration: 200 });
setIsCollapsed(!isCollapsed);
};
return (
<View style={styles.categorySection}>
<Pressable style={styles.categoryHeader} onPress={handleToggleCollapse}>
<View style={styles.categoryHeaderLeft}>
<Ionicons
name={getPackIconName(category.icon)}
size={16}
color={colors.textSecondary}
/>
<Text style={styles.categoryName}>{category.name}</Text>
{enabledCount > 0 && (
<View style={styles.categoryBadge}>
<Text style={styles.categoryBadgeText}>{enabledCount}</Text>
</View>
)}
</View>
<Animated.View style={chevronStyle}>
<Ionicons name="chevron-down" size={20} color={colors.textSecondary} />
</Animated.View>
</Pressable>
{!isCollapsed && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={styles.packsList}
>
{packs.map((pack) => (
<PackRow
key={pack.id}
pack={pack}
isEnabled={enabledPackIds.includes(pack.id)}
isPremiumLocked={pack.is_premium && !hasPremiumAccess}
isExpanded={expandedPackId === pack.id}
onPress={() => onPackPress(pack.id)}
onToggle={() => onPackToggle(pack)}
onInfoPress={() => onPackInfo(pack)}
/>
))}
</Animated.View>
)}
</View>
);
}
export default function QuestionPacksScreen() {
const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks, showAllIntensities, setShowAllIntensities } = usePacksStore();
const { user, partner } = useAuthStore();
const { subscription } = useSubscriptionStore();
const [expandedPackId, setExpandedPackId] = useState<string | null>(null);
const [showPaywall, setShowPaywall] = useState(false);
const [showTeaser, setShowTeaser] = useState(false);
const [teaserPack, setTeaserPack] = useState<QuestionPack | null>(null);
const scrollY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
const hasPremiumAccess = useMemo(() => {
return user?.is_premium || partner?.is_premium || subscription.isProUser;
}, [user?.is_premium, partner?.is_premium, subscription.isProUser]);
const packsByCategory = useMemo(() => {
const grouped = new Map<string, QuestionPack[]>();
categories.forEach(cat => grouped.set(cat.id, []));
grouped.set("uncategorized", []);
packs.forEach(pack => {
const catId = pack.category_id || "uncategorized";
const existing = grouped.get(catId) || [];
existing.push(pack);
grouped.set(catId, existing);
});
return grouped;
}, [packs, categories]);
const handlePackPress = useCallback((packId: string) => {
setExpandedPackId(current => current === packId ? null : packId);
}, []);
const handlePackToggle = useCallback(async (pack: QuestionPack) => {
if (pack.is_premium && !hasPremiumAccess) {
setTeaserPack(pack);
setShowTeaser(true);
return;
}
const result = await togglePack(pack.id);
if (!result.success && result.reason === "no_couple") {
Alert.alert(
"Pair with Partner",
"You need to pair with your partner before you can customise question packs.",
[
{ text: "Later", style: "cancel" },
{ text: "Pair Now", onPress: () => router.push("/pairing") }
]
);
}
}, [hasPremiumAccess, togglePack]);
const handlePackInfo = useCallback((pack: QuestionPack) => {
const isPremiumLocked = pack.is_premium && !hasPremiumAccess;
if (isPremiumLocked) {
setTeaserPack(pack);
setShowTeaser(true);
} else {
router.push(`/pack/${pack.id}`);
}
}, [hasPremiumAccess]);
const handleTeaserUnlock = useCallback(() => {
setShowTeaser(false);
setShowPaywall(true);
}, []);
const handleTeaserClose = useCallback(() => {
setShowTeaser(false);
setTeaserPack(null);
}, []);
const handleToggleComfortZone = useCallback((value: boolean) => {
setShowAllIntensities(value);
}, [setShowAllIntensities]);
const enabledPackCount = enabledPackIds.length;
const totalQuestions = packs
.filter(p => enabledPackIds.includes(p.id))
.reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0);
// Animated styles for collapsing header
const largeHeaderStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE * 0.5],
[1, 0],
Extrapolation.CLAMP
);
const translateY = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE],
[0, -20],
Extrapolation.CLAMP
);
return { opacity, transform: [{ translateY }] };
});
const compactHeaderStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE],
[0, 1],
Extrapolation.CLAMP
);
return { opacity };
});
const navBarBackgroundStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE * 0.8],
[0, 1],
Extrapolation.CLAMP
);
return { opacity };
});
return (
<GradientBackground>
<View style={styles.container}>
{/* Fixed Nav Bar */}
<View style={styles.navBar}>
{/* Nav bar background with blur */}
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
) : (
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
)}
</Animated.View>
{/* Back button */}
<Pressable
style={styles.backButton}
onPress={() => router.back()}
hitSlop={8}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</Pressable>
{/* Compact title (fades in on scroll) */}
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]}>
Question Packs
</Animated.Text>
{/* Spacer for alignment */}
<View style={styles.navBarSpacer} />
</View>
{/* Scrollable Content */}
<Animated.ScrollView
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
refreshControl={
<RefreshControl
refreshing={isLoading}
onRefresh={fetchPacks}
tintColor={colors.primary}
colors={[colors.primary]}
progressViewOffset={HEADER_MAX_HEIGHT}
/>
}
contentContainerStyle={styles.scrollContent}
>
{/* Large Header (fades out on scroll) */}
<Animated.View style={[styles.largeHeader, largeHeaderStyle]}>
<Text style={styles.label}>COLLECTION</Text>
<Text style={styles.title}>Question Packs</Text>
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<View style={styles.statsBadge}>
<Ionicons name="checkmark-circle" size={14} color={colors.primary} />
<Text style={styles.statsBadgeText}>
{totalQuestions} {totalQuestions === 1 ? "question" : "questions"} from {enabledPackCount} {enabledPackCount === 1 ? "pack" : "packs"}
</Text>
</View>
<Pressable
style={styles.comfortZoneToggle}
onPress={() => handleToggleComfortZone(!showAllIntensities)}
>
<View style={styles.comfortZoneToggleLeft}>
<Ionicons
name={showAllIntensities ? "flame" : "flame-outline"}
size={16}
color={showAllIntensities ? colors.primary : colors.textSecondary}
/>
<Text style={styles.comfortZoneToggleText}>
Show all intensities
</Text>
</View>
<GlassToggle
value={showAllIntensities}
onValueChange={handleToggleComfortZone}
/>
</Pressable>
</Animated.View>
{/* Categories */}
<View style={styles.categoriesContainer}>
{categories.map(category => {
const categoryPacks = packsByCategory.get(category.id) || [];
if (categoryPacks.length === 0) return null;
return (
<CategorySection
key={category.id}
category={category}
packs={categoryPacks}
enabledPackIds={enabledPackIds}
hasPremiumAccess={hasPremiumAccess}
expandedPackId={expandedPackId}
onPackPress={handlePackPress}
onPackToggle={handlePackToggle}
onPackInfo={handlePackInfo}
/>
);
})}
{(packsByCategory.get("uncategorized")?.length || 0) > 0 && (
<CategorySection
category={{
id: "uncategorized",
name: "Other",
description: null,
icon: "📁",
color: null,
sort_order: 999,
created_at: "",
is_public: true
}}
packs={packsByCategory.get("uncategorized") || []}
enabledPackIds={enabledPackIds}
hasPremiumAccess={hasPremiumAccess}
expandedPackId={expandedPackId}
onPackPress={handlePackPress}
onPackToggle={handlePackToggle}
onPackInfo={handlePackInfo}
/>
)}
</View>
</Animated.ScrollView>
{/* Modals */}
{teaserPack && (
<PackTeaser
visible={showTeaser}
packId={teaserPack.id}
packName={teaserPack.name}
packIcon={teaserPack.icon}
onClose={handleTeaserClose}
onUnlock={handleTeaserUnlock}
/>
)}
<Paywall
visible={showPaywall}
onClose={() => setShowPaywall(false)}
onSuccess={() => fetchPacks()}
/>
</View>
</GradientBackground>
);
}
const NAV_BAR_HEIGHT = 44;
const STATUS_BAR_HEIGHT = 60; // Match other pages
const styles = StyleSheet.create({
container: {
flex: 1,
},
// Fixed Nav Bar
navBar: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT,
flexDirection: "row",
alignItems: "center",
paddingTop: STATUS_BAR_HEIGHT - 10,
paddingHorizontal: spacing.md,
zIndex: 100,
},
navBarBackground: {
...StyleSheet.absoluteFillObject,
borderBottomWidth: 1,
borderBottomColor: "rgba(255, 255, 255, 0.1)",
overflow: "hidden",
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(255, 255, 255, 0.1)",
justifyContent: "center",
alignItems: "center",
},
navBarTitle: {
flex: 1,
...typography.headline,
color: colors.text,
textAlign: "center",
marginRight: 40, // Balance with back button
},
navBarSpacer: {
width: 0,
},
// Large Header
largeHeader: {
alignItems: "center",
paddingBottom: spacing.lg,
},
label: {
...typography.caption1,
fontWeight: "600",
letterSpacing: 3,
color: colors.primary,
textAlign: "center",
marginBottom: spacing.xs,
},
title: {
...typography.title1,
color: colors.text,
textAlign: "center",
},
separator: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginVertical: spacing.md,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: "rgba(233, 69, 96, 0.3)",
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.primary,
transform: [{ rotate: "45deg" }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
statsBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(233, 69, 96, 0.1)",
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: "rgba(233, 69, 96, 0.2)",
gap: spacing.sm,
},
statsBadgeText: {
...typography.caption1,
color: colors.textSecondary,
fontWeight: "500",
},
comfortZoneToggle: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: "rgba(255, 255, 255, 0.05)",
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
marginTop: spacing.md,
width: "100%",
maxWidth: 280,
},
comfortZoneToggleLeft: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
comfortZoneToggleText: {
...typography.subhead,
color: colors.textSecondary,
fontWeight: "500",
},
// Scroll Content
scrollContent: {
paddingTop: 60, // Match other pages
paddingBottom: Platform.OS === "ios" ? 120 : 100,
},
categoriesContainer: {
paddingHorizontal: spacing.lg,
},
// Category Section
categorySection: {
marginBottom: spacing.lg,
},
categoryHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: spacing.sm,
marginBottom: spacing.xs,
},
categoryHeaderLeft: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
categoryName: {
...typography.headline,
color: colors.text,
textTransform: "uppercase",
letterSpacing: 1,
fontSize: 13,
},
categoryBadge: {
backgroundColor: colors.primary,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
minWidth: 20,
alignItems: "center",
},
categoryBadgeText: {
...typography.caption2,
color: colors.text,
fontWeight: "600",
},
packsList: {
backgroundColor: "rgba(255, 255, 255, 0.03)",
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.glass.border,
overflow: "hidden",
},
// Pack Row
packRow: {
borderBottomWidth: 1,
borderBottomColor: colors.glass.border,
},
packRowMain: {
flexDirection: "row",
alignItems: "center",
padding: spacing.md,
gap: spacing.md,
},
packIcon: {
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: "rgba(255, 255, 255, 0.05)",
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.08)",
},
packIconEnabled: {
backgroundColor: "rgba(233, 69, 96, 0.1)",
borderColor: "rgba(233, 69, 96, 0.2)",
},
packContent: {
flex: 1,
},
packTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
},
packName: {
...typography.body,
color: colors.text,
fontWeight: "500",
flexShrink: 1,
},
premiumBadge: {
flexDirection: "row",
alignItems: "center",
gap: 4,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: radius.full,
backgroundColor: "rgba(212, 175, 55, 0.12)",
borderWidth: 1,
borderColor: "rgba(212, 175, 55, 0.25)",
},
premiumBadgeLocked: {
backgroundColor: "rgba(212, 175, 55, 0.18)",
borderColor: "rgba(212, 175, 55, 0.35)",
},
premiumBadgeText: {
...typography.caption2,
color: colors.premium.gold,
fontWeight: "700",
letterSpacing: 0.6,
},
packMeta: {
...typography.caption1,
color: colors.textTertiary,
marginTop: 2,
},
packMetaEnabled: {
color: colors.primary,
},
packIntensityRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
marginTop: spacing.xs,
},
packIntensityFlames: {
flexDirection: "row",
alignItems: "center",
gap: 2,
},
packIntensityText: {
...typography.caption2,
color: colors.textSecondary,
fontWeight: "600",
},
// Expanded Pack
packExpanded: {
paddingHorizontal: spacing.md,
paddingBottom: spacing.md,
paddingTop: 0,
},
packDescription: {
...typography.subhead,
color: colors.textSecondary,
lineHeight: 20,
marginBottom: spacing.md,
},
packActions: {
flexDirection: "row",
gap: spacing.sm,
},
packInfoButton: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 1,
borderColor: colors.glass.border,
},
packInfoButtonText: {
...typography.subhead,
color: colors.textSecondary,
},
packToggleButton: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
backgroundColor: "rgba(255, 255, 255, 0.08)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
},
packToggleButtonEnabled: {
backgroundColor: "rgba(233, 69, 96, 0.1)",
borderColor: "rgba(233, 69, 96, 0.3)",
},
packToggleButtonLocked: {
backgroundColor: "rgba(212, 175, 55, 0.1)",
borderColor: "rgba(212, 175, 55, 0.3)",
},
packToggleButtonText: {
...typography.subhead,
color: colors.text,
fontWeight: "500",
},
packToggleButtonTextEnabled: {
color: colors.primary,
},
packToggleButtonTextLocked: {
color: colors.premium.gold,
},
});
+51 -37
View File
@@ -418,10 +418,56 @@ const styles = StyleSheet.create({
width: 44, width: 44,
height: 44, height: 44,
borderRadius: 22, borderRadius: 22,
backgroundColor: colors.glass.background, backgroundColor: colors.backgroundLight,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
marginRight: spacing.md, marginRight: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
// ...
input: {
backgroundColor: colors.background, // Flat background
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
padding: spacing.md,
...typography.headline,
color: colors.text,
textAlign: "center",
letterSpacing: 4,
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
marginBottom: spacing.md,
},
// ...
dividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.border,
},
// ...
copyIcon: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.background,
justifyContent: "center",
alignItems: "center",
marginLeft: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
// ...
waitingBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
}, },
title: { title: {
...typography.title1, ...typography.title1,
@@ -462,29 +508,13 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm, marginBottom: spacing.sm,
marginLeft: spacing.xs, marginLeft: spacing.xs,
}, },
input: {
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
padding: spacing.md,
...typography.headline,
color: colors.text,
textAlign: "center",
letterSpacing: 4,
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
marginBottom: spacing.md,
},
divider: { divider: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
marginVertical: spacing.lg, marginVertical: spacing.lg,
}, },
dividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.glass.border,
},
dividerText: { dividerText: {
...typography.subhead, ...typography.subhead,
color: colors.textTertiary, color: colors.textTertiary,
@@ -501,15 +531,7 @@ const styles = StyleSheet.create({
letterSpacing: 6, letterSpacing: 6,
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace", fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
}, },
copyIcon: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
marginLeft: spacing.md,
},
tapToCopy: { tapToCopy: {
...typography.caption1, ...typography.caption1,
color: colors.textTertiary, color: colors.textTertiary,
@@ -520,15 +542,7 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
marginTop: spacing.xl, marginTop: spacing.xl,
}, },
waitingBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.glass.background,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.sm,
},
waitingText: { waitingText: {
...typography.subhead, ...typography.subhead,
color: colors.textTertiary, color: colors.textTertiary,
+23 -23
View File
@@ -163,14 +163,35 @@ const styles = StyleSheet.create({
opacity: 0.6, opacity: 0.6,
}, },
badge: { badge: {
backgroundColor: 'rgba(232, 164, 174, 0.1)', backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
borderRadius: radius.full, borderRadius: radius.full,
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(232, 164, 174, 0.2)', borderColor: colors.border,
marginBottom: spacing.xl, marginBottom: spacing.xl,
}, },
interestButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.sm,
},
interestButtonActive: {
backgroundColor: colors.background,
borderColor: colors.primary,
},
interestButtonText: {
...typography.subhead,
fontWeight: '600',
color: ACCENT_COLOR,
},
badgeText: { badgeText: {
...typography.caption2, ...typography.caption2,
fontWeight: '600', fontWeight: '600',
@@ -208,25 +229,4 @@ const styles = StyleSheet.create({
marginTop: spacing.xl, marginTop: spacing.xl,
minHeight: 44, minHeight: 44,
}, },
interestButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(232, 164, 174, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(232, 164, 174, 0.2)',
gap: spacing.sm,
},
interestButtonActive: {
backgroundColor: 'rgba(232, 164, 174, 0.15)',
borderColor: 'rgba(232, 164, 174, 0.3)',
},
interestButtonText: {
...typography.subhead,
fontWeight: '600',
color: ACCENT_COLOR,
},
}); });
+342 -109
View File
@@ -2,6 +2,8 @@ import { useState, useEffect, useRef, useCallback } from "react";
import { View, StyleSheet, Text, ActivityIndicator, Platform, TouchableOpacity } from "react-native"; import { View, StyleSheet, Text, ActivityIndicator, Platform, TouchableOpacity } from "react-native";
import { useLocalSearchParams, router, useFocusEffect } from "expo-router"; import { useLocalSearchParams, router, useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import * as FileSystem from "expo-file-system";
import { decode } from "base64-arraybuffer";
import Animated, { import Animated, {
FadeIn, FadeIn,
FadeInUp, FadeInUp,
@@ -17,16 +19,45 @@ import { supabase } from "../../src/lib/supabase";
import { usePacksStore, useAuthStore } from "../../src/store"; import { usePacksStore, useAuthStore } from "../../src/store";
import { useAmbientOrbAnimation } from "../../src/hooks"; import { useAmbientOrbAnimation } from "../../src/hooks";
import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions"; import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions";
import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen";
import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler"; import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler";
// import SwipeCard from "../../src/components/SwipeCard"; import {
import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // PoC: Premium styling QuestionCard,
import { SwipeTutorial } from "../../src/components/tutorials"; QuestionCardText,
QuestionCardPhoto,
QuestionCardAudio,
QuestionCardWhoLikely
} from "../../src/components/questions";
import { QuestionFeedbackModal } from "../../src/components/feedback"; import { QuestionFeedbackModal } from "../../src/components/feedback";
import { MatchConfetti } from "../../src/components/MatchConfetti";
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui"; import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { Paywall } from "../../src/components/paywall"; import { Paywall } from "../../src/components/paywall";
import { Events } from "../../src/lib/analytics"; import { Events } from "../../src/lib/analytics";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme"; import { colors, gradients, spacing, typography, radius, shadows, getCategoryColor } from "../../src/theme";
import type { AnswerType } from "../../src/types";
// Response data types for different question types
interface TextResponseData {
type: 'text_answer';
text: string;
}
interface PhotoResponseData {
type: 'photo';
media_path: string;
}
interface AudioResponseData {
type: 'audio';
media_path: string;
duration_seconds: number;
}
interface WhoLikelyResponseData {
type: 'who_likely';
chosen_user_id: string;
}
type ResponseData = TextResponseData | PhotoResponseData | AudioResponseData | WhoLikelyResponseData;
interface DailyLimitInfo { interface DailyLimitInfo {
responses_today: number; responses_today: number;
@@ -47,15 +78,25 @@ export default function SwipeScreen() {
const [questions, setQuestions] = useState<any[]>([]); const [questions, setQuestions] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [showTutorial, setShowTutorial] = useState(false);
const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null); const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [isBlocked, setIsBlocked] = useState(false); const [isBlocked, setIsBlocked] = useState(false);
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null); const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
const [dailyLimitInfo, setDailyLimitInfo] = useState<DailyLimitInfo | null>(null); const [dailyLimitInfo, setDailyLimitInfo] = useState<DailyLimitInfo | null>(null);
const [showPaywall, setShowPaywall] = useState(false); const [showPaywall, setShowPaywall] = useState(false);
const [packContext, setPackContext] = useState<{ name: string; icon: string } | null>(null); const [packContext, setPackContext] = useState<{ name: string; icon: string } | null>(null);
const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore(); const [showConfetti, setShowConfetti] = useState(false);
const { partner, couple } = useAuthStore(); const { enabledPackIds, ensureEnabledPacksLoaded, packs } = usePacksStore();
const { user, partner, couple } = useAuthStore();
// Supported question types - filter out types the UI doesn't support yet
const supportedTypes = ['swipe', 'text_answer', 'photo', 'audio', 'who_likely'];
// Filter questions to only supported types (safety during rollout)
// Must be calculated early since it's used by effectiveTotal and throughout rendering
const filteredQuestions = questions.filter(q =>
!q.question_type || supportedTypes.includes(q.question_type)
);
const hasTrackedExhausted = useRef(false); const hasTrackedExhausted = useRef(false);
const fetchRequestId = useRef(0); const fetchRequestId = useRef(0);
const lastFetchedMode = useRef<string | undefined>(undefined); const lastFetchedMode = useRef<string | undefined>(undefined);
@@ -134,8 +175,7 @@ export default function SwipeScreen() {
fetchQuestions(); fetchQuestions();
} }
}); });
checkTutorial(); }, [packId, mode, startQuestionId]);
}, [packId, mode]);
// Fetch pack context when packId is provided // Fetch pack context when packId is provided
useEffect(() => { useEffect(() => {
@@ -252,7 +292,7 @@ export default function SwipeScreen() {
// Calculate effective total questions (accounting for gap limit AND daily limit) // Calculate effective total questions (accounting for gap limit AND daily limit)
const effectiveTotal = (() => { const effectiveTotal = (() => {
let total = questions.length; let total = filteredQuestions.length;
// Apply gap limit if active // Apply gap limit if active
if (gapInfo && gapInfo.threshold > 0) { if (gapInfo && gapInfo.threshold > 0) {
@@ -270,23 +310,11 @@ export default function SwipeScreen() {
// Track when all questions are exhausted // Track when all questions are exhausted
useEffect(() => { useEffect(() => {
if (questions.length > 0 && currentIndex >= questions.length && !hasTrackedExhausted.current) { if (filteredQuestions.length > 0 && currentIndex >= filteredQuestions.length && !hasTrackedExhausted.current) {
hasTrackedExhausted.current = true; hasTrackedExhausted.current = true;
Events.allQuestionsExhausted(); Events.allQuestionsExhausted();
} }
}, [currentIndex, questions.length]); }, [currentIndex, filteredQuestions.length]);
const checkTutorial = async () => {
const seen = await hasSeenSwipeTutorial();
if (!seen) {
setShowTutorial(true);
}
};
const handleTutorialComplete = async () => {
await markSwipeTutorialSeen();
setShowTutorial(false);
};
// Filter out questions from disabled packs immediately when enabledPackIds changes // Filter out questions from disabled packs immediately when enabledPackIds changes
// Skip this filter in pending mode - user should answer ALL questions partner swiped on // Skip this filter in pending mode - user should answer ALL questions partner swiped on
@@ -328,7 +356,12 @@ export default function SwipeScreen() {
} }
// Filter out recently skipped questions // Filter out recently skipped questions
const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id)); let filtered = (data || []).filter((q: any) => !skippedIds.has(q.id));
// If no unskipped questions remain, show skipped ones again
if (filtered.length === 0 && data && data.length > 0) {
filtered = data;
}
// Always check answer gap and daily limit when user has partner and not viewing specific pack // Always check answer gap and daily limit when user has partner and not viewing specific pack
if (partner && !packId) { if (partner && !packId) {
@@ -469,33 +502,123 @@ export default function SwipeScreen() {
} }
}; };
const handleSwipe = async (direction: "left" | "right" | "up" | "down") => { // Upload media (photo/audio) to Supabase storage
const question = questions[currentIndex]; const uploadResponseMedia = async (
localUri: string,
questionId: string,
mediaType: 'photo' | 'audio'
): Promise<string | null> => {
if (!user?.id) return null;
setCurrentIndex(prev => prev + 1); try {
const timestamp = Date.now();
const extMatch = localUri.match(/\.(\w+)$/);
const ext = extMatch ? extMatch[1] : (mediaType === 'photo' ? 'jpg' : 'm4a');
const fileName = `${user.id}/${questionId}_${timestamp}.${ext}`;
const contentType = mediaType === 'photo'
? `image/${ext === 'jpg' ? 'jpeg' : ext}`
: `audio/${ext}`;
let fileBody: ArrayBuffer | Blob;
if (Platform.OS === 'web') {
const response = await fetch(localUri);
fileBody = await response.blob();
} else {
const base64 = await FileSystem.readAsStringAsync(localUri, {
encoding: FileSystem.EncodingType.Base64,
});
fileBody = decode(base64);
}
const { error } = await supabase.storage
.from('response-media')
.upload(fileName, fileBody, { contentType });
if (error) {
console.error('Media upload error:', error);
return null;
}
return fileName;
} catch (error) {
console.error('Media upload failed:', error);
return null;
}
};
// Handle answer submission from question cards (supports response_data for new question types)
const handleAnswer = async (questionId: string, answer: AnswerType | 'skip', responseData?: ResponseData) => {
const question = filteredQuestions[currentIndex];
// Handle skip - don't submit a response, just store it for later // Handle skip - don't submit a response, just store it for later
if (direction === "down") { if (answer === 'skip') {
await skipQuestion(question.id); setCurrentIndex(prev => prev + 1);
await skipQuestion(questionId);
Events.questionSkipped(); Events.questionSkipped();
return; return;
} }
const answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe";
try { try {
const { error } = await invokeWithAuthRetry("submit-response", { // Upload media if response contains photo or audio with local URI
let finalResponseData = responseData;
if (responseData) {
if (responseData.type === 'photo' && responseData.media_path) {
setIsUploading(true);
try {
const uploadedPath = await uploadResponseMedia(responseData.media_path, questionId, 'photo');
if (uploadedPath) {
finalResponseData = { ...responseData, media_path: uploadedPath };
} else {
console.error('Photo upload failed, skipping response_data');
finalResponseData = undefined;
}
} finally {
setIsUploading(false);
}
} else if (responseData.type === 'audio' && responseData.media_path) {
setIsUploading(true);
try {
const uploadedPath = await uploadResponseMedia(responseData.media_path, questionId, 'audio');
if (uploadedPath) {
finalResponseData = { ...responseData, media_path: uploadedPath };
} else {
console.error('Audio upload failed, skipping response_data');
finalResponseData = undefined;
}
} finally {
setIsUploading(false);
}
}
}
const { data, error } = await invokeWithAuthRetry("submit-response", {
body: { body: {
question_id: question.id, question_id: questionId,
answer, answer,
response_data: finalResponseData,
}, },
}); });
if (error) { if (error) {
console.error("Submit response error:", error); console.error("Submit response error:", error);
// Advance to next question even on error
setCurrentIndex(prev => prev + 1);
} else { } else {
Events.questionAnswered(answer, question.pack_id); Events.questionAnswered(answer, question.pack_id);
// Check if a match was created
const hasMatch = data?.match != null;
if (hasMatch) {
// Show confetti celebration - delay advancing to next question
setShowConfetti(true);
// Confetti component will call onAnimationComplete which advances
} else {
// No match - advance immediately
setCurrentIndex(prev => prev + 1);
}
// Skip limit checks in pending mode - user is just catching up // Skip limit checks in pending mode - user is just catching up
if (mode !== 'pending') { if (mode !== 'pending') {
// Increment local daily count if we have limit info // Increment local daily count if we have limit info
@@ -514,6 +637,131 @@ export default function SwipeScreen() {
} }
} catch (error) { } catch (error) {
console.error("Failed to submit response", error); console.error("Failed to submit response", error);
// Advance to next question even on error
setCurrentIndex(prev => prev + 1);
}
};
// Handle confetti animation completion
const handleConfettiComplete = useCallback(() => {
setShowConfetti(false);
setCurrentIndex(prev => prev + 1);
}, []);
// Get pack info for a question - use packContext if viewing a specific pack, otherwise question's pack
const getQuestionPackInfo = (question: any) => {
// If viewing specific pack, use that context but try to enrich with color from store
if (packId) {
const pack = packs.find(p => p.id === packId);
const categoryColor = pack ? getCategoryColor(pack.category) : undefined;
if (packContext) {
return { ...packContext, color: categoryColor };
}
if (pack) {
return { name: pack.name, icon: pack.icon || 'layers', color: categoryColor };
}
}
// For pending mode or mixed mode, look up pack details
if (question.pack_id) {
const pack = packs.find(p => p.id === question.pack_id);
const categoryColor = pack ? getCategoryColor(pack.category) : undefined;
// Pending questions might have name/icon embedded if pack not found in store
if (question.pack_name) {
return {
name: question.pack_name,
icon: question.pack_icon || 'layers',
color: categoryColor
};
}
if (pack) {
return { name: pack.name, icon: pack.icon || 'layers', color: categoryColor };
}
}
return null;
};
// Render the appropriate question card based on question_type
const renderQuestionCard = (question: any) => {
// For backwards compatibility: default to 'swipe' if no question_type
const questionType = question.question_type || 'swipe';
const packInfo = getQuestionPackInfo(question);
switch (questionType) {
case 'text_answer':
return (
<QuestionCardText
key={question.id}
question={question}
packInfo={packInfo}
onAnswer={(answer, responseData) => handleAnswer(question.id, answer, responseData)}
onReport={() => setFeedbackQuestion({
id: question.id,
text: question.text
})}
/>
);
case 'photo':
return (
<QuestionCardPhoto
key={question.id}
question={question}
packInfo={packInfo}
onAnswer={(answer, responseData) => handleAnswer(question.id, answer, responseData)}
onReport={() => setFeedbackQuestion({
id: question.id,
text: question.text
})}
/>
);
case 'audio':
return (
<QuestionCardAudio
key={question.id}
question={question}
packInfo={packInfo}
onAnswer={(answer, responseData) => handleAnswer(question.id, answer, responseData)}
onReport={() => setFeedbackQuestion({
id: question.id,
text: question.text
})}
/>
);
case 'who_likely':
return (
<QuestionCardWhoLikely
key={question.id}
question={question}
packInfo={packInfo}
user={{ id: user?.id || '', name: user?.name || undefined, avatar_url: user?.avatar_url }}
partner={{ id: partner?.id || '', name: partner?.name || undefined, avatar_url: partner?.avatar_url }}
onAnswer={(responseData) => handleAnswer(question.id, 'yes', responseData)}
onSkip={() => handleAnswer(question.id, 'skip')}
onReport={() => setFeedbackQuestion({
id: question.id,
text: question.text
})}
/>
);
case 'swipe':
default:
// Standard swipe card for 'swipe' type and any unknown types (backwards compatibility)
return (
<QuestionCard
key={question.id}
question={question}
packInfo={packInfo}
onAnswer={(answer) => handleAnswer(question.id, answer)}
onReport={() => setFeedbackQuestion({
id: question.id,
text: question.text
})}
/>
);
} }
}; };
@@ -600,7 +848,7 @@ export default function SwipeScreen() {
const LIMIT_ACCENT = colors.premium.gold; const LIMIT_ACCENT = colors.premium.gold;
return ( return (
<GradientBackground> <GradientBackground>
{/* Ambient Orbs - Gold/warm glow */} {/* Ambient Orbs - Gold/warm glow - Commented out for flat design
<Animated.View style={[styles.ambientOrb, styles.dailyLimitOrbTop, orbStyle1]} pointerEvents="none"> <Animated.View style={[styles.ambientOrb, styles.dailyLimitOrbTop, orbStyle1]} pointerEvents="none">
<LinearGradient <LinearGradient
colors={[colors.premium.goldGlow, 'transparent']} colors={[colors.premium.goldGlow, 'transparent']}
@@ -617,6 +865,7 @@ export default function SwipeScreen() {
end={{ x: 0, y: 0 }} end={{ x: 0, y: 0 }}
/> />
</Animated.View> </Animated.View>
*/}
<View style={styles.centerContainer}> <View style={styles.centerContainer}>
<Animated.View <Animated.View
@@ -826,10 +1075,10 @@ export default function SwipeScreen() {
<Text style={styles.waitingTeaser}>Your journey of discovery awaits</Text> <Text style={styles.waitingTeaser}>Your journey of discovery awaits</Text>
<GlassButton <GlassButton
onPress={() => router.push("/packs")} onPress={() => router.push("/")}
style={{ marginTop: spacing.lg }} style={{ marginTop: spacing.lg }}
> >
Choose Packs Browse Packs
</GlassButton> </GlassButton>
</Animated.View> </Animated.View>
</View> </View>
@@ -837,7 +1086,7 @@ export default function SwipeScreen() {
); );
} }
if (currentIndex >= questions.length) { if (currentIndex >= filteredQuestions.length) {
const CAUGHT_UP_ACCENT = colors.premium.rose; const CAUGHT_UP_ACCENT = colors.premium.rose;
const isPackMode = !!packId; const isPackMode = !!packId;
const isPendingMode = mode === 'pending'; const isPendingMode = mode === 'pending';
@@ -878,8 +1127,8 @@ export default function SwipeScreen() {
{isPendingMode {isPendingMode
? "You've answered all the questions your partner swiped on. Nice work! Check back later for more." ? "You've answered all the questions your partner swiped on. Nice work! Check back later for more."
: isPackMode : isPackMode
? "You've answered all questions in this pack. Explore other packs to discover more!" ? "You've answered all questions in this pack. Head home to discover more!"
: "You've answered all available questions. New questions are added regularly, or explore different packs." : "You've answered all available questions. New questions are added regularly, or explore different packs from home."
} }
</Text> </Text>
@@ -907,8 +1156,8 @@ export default function SwipeScreen() {
<Text style={styles.waitingFeatureText}>New questions weekly</Text> <Text style={styles.waitingFeatureText}>New questions weekly</Text>
</View> </View>
<View style={styles.waitingFeatureItem}> <View style={styles.waitingFeatureItem}>
<Ionicons name="layers-outline" size={16} color={CAUGHT_UP_ACCENT} /> <Ionicons name="home-outline" size={16} color={CAUGHT_UP_ACCENT} />
<Text style={styles.waitingFeatureText}>Try different packs</Text> <Text style={styles.waitingFeatureText}>Explore from home</Text>
</View> </View>
<View style={styles.waitingFeatureItem}> <View style={styles.waitingFeatureItem}>
<Ionicons name="chatbubbles-outline" size={16} color={CAUGHT_UP_ACCENT} /> <Ionicons name="chatbubbles-outline" size={16} color={CAUGHT_UP_ACCENT} />
@@ -936,10 +1185,10 @@ export default function SwipeScreen() {
</GlassButton> </GlassButton>
) : isPackMode ? ( ) : isPackMode ? (
<GlassButton <GlassButton
onPress={() => router.back()} onPress={() => router.push("/")}
style={{ marginTop: spacing.lg }} style={{ marginTop: spacing.lg }}
> >
Explore More Packs Back to Home
</GlassButton> </GlassButton>
) : ( ) : (
<GlassButton <GlassButton
@@ -958,7 +1207,7 @@ export default function SwipeScreen() {
return ( return (
<GradientBackground> <GradientBackground>
{/* Ambient Orbs - Premium gold/rose */} {/* Ambient Orbs - Premium gold/rose - Commented out for flat design
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none"> <Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
<LinearGradient <LinearGradient
colors={[colors.premium.goldGlow, 'transparent']} colors={[colors.premium.goldGlow, 'transparent']}
@@ -975,6 +1224,7 @@ export default function SwipeScreen() {
end={{ x: 0, y: 0 }} end={{ x: 0, y: 0 }}
/> />
</Animated.View> </Animated.View>
*/}
<View style={styles.container}> <View style={styles.container}>
{/* Header */} {/* Header */}
@@ -995,14 +1245,14 @@ export default function SwipeScreen() {
{mode === 'pending' ? 'YOUR TURN' : packContext ? packContext.name.toUpperCase() : 'EXPLORE'} {mode === 'pending' ? 'YOUR TURN' : packContext ? packContext.name.toUpperCase() : 'EXPLORE'}
</Text> </Text>
<Text style={styles.progressText}> <Text style={styles.progressText}>
{currentIndex + 1} of {effectiveTotal || questions.length} {currentIndex + 1} of {effectiveTotal || filteredQuestions.length}
</Text> </Text>
<View style={[styles.progressBar, styles.progressBarPremium]}> <View style={[styles.progressBar, styles.progressBarPremium]}>
<Animated.View <Animated.View
style={[ style={[
styles.progressFill, styles.progressFill,
styles.progressFillPremium, styles.progressFillPremium,
{ width: `${((currentIndex + 1) / (effectiveTotal || questions.length || 1)) * 100}%` } { width: `${((currentIndex + 1) / (effectiveTotal || filteredQuestions.length || 1)) * 100}%` }
]} ]}
> >
{/* Shimmer sweep */} {/* Shimmer sweep */}
@@ -1025,52 +1275,23 @@ export default function SwipeScreen() {
{/* Card Stack */} {/* Card Stack */}
<View style={styles.cardContainer}> <View style={styles.cardContainer}>
{/* Third card (deepest) */} {/* Third card (deepest) */}
{questions[currentIndex + 2] && ( {filteredQuestions[currentIndex + 2] && (
<View style={[styles.stackCard, styles.stackCardThird]} /> <View style={[styles.stackCard, styles.stackCardThird]} />
)} )}
{/* Second card */} {/* Second card */}
{questions[currentIndex + 1] && ( {filteredQuestions[currentIndex + 1] && (
<View style={[styles.stackCard, styles.stackCardSecond]} /> <View style={[styles.stackCard, styles.stackCardSecond]} />
)} )}
{/* Active card */} {/* Active card - render appropriate component based on question_type */}
<SwipeCard {renderQuestionCard(filteredQuestions[currentIndex])}
key={questions[currentIndex].id}
question={questions[currentIndex]}
onSwipe={handleSwipe}
onReport={() => setFeedbackQuestion({
id: questions[currentIndex].id,
text: questions[currentIndex].text
})}
/>
</View> </View>
{/* Hint - Premium styling */}
<Animated.View
entering={FadeIn.delay(500).duration(400)}
style={styles.hintContainer}
>
<Text style={styles.hintTextPremium}>Swipe or tap to answer</Text>
<TouchableOpacity
onPress={() => router.push({ pathname: "/my-answers", params: { returnTo: "/(app)/swipe" } })}
style={styles.editLinkButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Text style={styles.editLinkText}>Edit previous answers</Text>
</TouchableOpacity>
</Animated.View>
{/* Bottom spacing for tab bar */} {/* Bottom spacing for tab bar */}
<View style={styles.bottomSpacer} /> <View style={styles.bottomSpacer} />
</View> </View>
{/* Tutorial Overlay */}
{showTutorial && (
<SwipeTutorial onComplete={handleTutorialComplete} />
)}
{/* Question Feedback Modal */} {/* Question Feedback Modal */}
<QuestionFeedbackModal <QuestionFeedbackModal
visible={!!feedbackQuestion} visible={!!feedbackQuestion}
@@ -1079,6 +1300,12 @@ export default function SwipeScreen() {
questionText={feedbackQuestion?.text || ''} questionText={feedbackQuestion?.text || ''}
/> />
{/* Match Confetti Animation */}
<MatchConfetti
visible={showConfetti}
onAnimationComplete={handleConfettiComplete}
/>
{/* Paywall Modal */} {/* Paywall Modal */}
<Paywall <Paywall
visible={showPaywall} visible={showPaywall}
@@ -1089,6 +1316,16 @@ export default function SwipeScreen() {
fetchQuestions(); fetchQuestions();
}} }}
/> />
{/* Upload spinner overlay */}
{isUploading && (
<View style={styles.uploadOverlay}>
<View style={styles.uploadSpinnerContainer}>
<ActivityIndicator size="large" color={colors.premium.gold} />
<Text style={styles.uploadText}>Uploading...</Text>
</View>
</View>
)}
</GradientBackground> </GradientBackground>
); );
} }
@@ -1193,11 +1430,11 @@ const styles = StyleSheet.create({
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}, },
// Card stack - Premium styling // Card stack - Premium styling (background cards)
stackCard: { stackCard: {
position: "absolute", position: "absolute",
width: "85%", width: "85%",
height: 500, height: "85%",
backgroundColor: '#0d0d1a', backgroundColor: '#0d0d1a',
borderRadius: radius.xl, borderRadius: radius.xl,
borderWidth: 1, borderWidth: 1,
@@ -1213,32 +1450,6 @@ const styles = StyleSheet.create({
opacity: 0.2, opacity: 0.2,
...shadows.sm, ...shadows.sm,
}, },
hintContainer: {
alignItems: "center",
paddingBottom: spacing.md,
},
hintText: {
...typography.caption1,
color: colors.textTertiary,
},
hintTextPremium: {
...typography.caption1,
fontStyle: 'italic',
color: colors.textTertiary,
letterSpacing: 0.5,
},
editLinkButton: {
marginTop: spacing.md,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
},
editLinkText: {
...typography.caption2,
color: colors.premium.gold,
textDecorationLine: 'underline',
opacity: 0.8,
letterSpacing: 0.5,
},
bottomSpacer: { bottomSpacer: {
height: Platform.OS === 'ios' ? 100 : 80, height: Platform.OS === 'ios' ? 100 : 80,
}, },
@@ -1443,4 +1654,26 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
color: '#000', color: '#000',
}, },
// Upload overlay styles
uploadOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 100,
},
uploadSpinnerContainer: {
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.lg,
borderRadius: radius.lg,
alignItems: 'center',
gap: spacing.md,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
},
uploadText: {
...typography.subhead,
color: colors.text,
},
}); });
+10 -6
View File
@@ -768,10 +768,18 @@ const styles = StyleSheet.create({
}, },
modeToggleContainer: { modeToggleContainer: {
flexDirection: "row", flexDirection: "row",
backgroundColor: colors.glass.background, backgroundColor: colors.background, // Flat background
borderRadius: radius.md, borderRadius: radius.md,
padding: 4, padding: 4,
marginBottom: spacing.lg, marginBottom: spacing.lg,
borderWidth: 1,
borderColor: colors.border,
},
// ...
dividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.border,
}, },
modeToggle: { modeToggle: {
flex: 1, flex: 1,
@@ -832,11 +840,7 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
marginBottom: spacing.lg, marginBottom: spacing.lg,
}, },
dividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.glass.border,
},
dividerText: { dividerText: {
...typography.caption1, ...typography.caption1,
color: colors.textTertiary, color: colors.textTertiary,
+3 -2
View File
@@ -1,6 +1,7 @@
import { Redirect } from "expo-router"; import { Redirect } from "expo-router";
import { useAuthStore } from "../src/store"; import { useAuthStore } from "../src/store";
import { View, ActivityIndicator, StyleSheet } from "react-native"; import { View, ActivityIndicator, StyleSheet } from "react-native";
import { needsOnboarding } from "../src/constants/onboarding";
export default function Index() { export default function Index() {
const { isAuthenticated, isLoading, user } = useAuthStore(); const { isAuthenticated, isLoading, user } = useAuthStore();
@@ -19,8 +20,8 @@ export default function Index() {
return <Redirect href="/(auth)/login" />; return <Redirect href="/(auth)/login" />;
} }
// If user hasn't completed onboarding, go to onboarding first // If user hasn't completed onboarding or needs to re-onboard, go to onboarding
if (!user?.onboarding_completed) { if (needsOnboarding(user?.onboarding_completed, user?.onboarding_version)) {
return <Redirect href="/(app)/onboarding" />; return <Redirect href="/(app)/onboarding" />;
} }
+115 -89
View File
@@ -242,12 +242,19 @@ export default function PackDetailsScreen() {
{category ? category.name.toUpperCase() : 'PREVIEW'} {category ? category.name.toUpperCase() : 'PREVIEW'}
</Text> </Text>
{/* Explicit badge */}
{pack?.is_explicit && (
<View style={styles.explicitBadge}>
<Ionicons name="flame" size={12} color={colors.text} />
<Text style={styles.explicitBadgeText}>18+ EXPLICIT</Text>
</View>
)}
{/* Icon with enhanced glow for premium */} {/* Icon with enhanced glow for premium */}
<View style={styles.iconWrapper}> <View style={styles.iconWrapper}>
{pack?.is_premium && ( {pack?.is_premium && (
<> <>
<View style={styles.iconGlowOuter} /> {/* Glow effects removed for flat design */}
<View style={styles.iconGlow} />
</> </>
)} )}
<View style={[ <View style={[
@@ -329,11 +336,7 @@ export default function PackDetailsScreen() {
{/* Fixed Nav Bar */} {/* Fixed Nav Bar */}
<View style={styles.navBar}> <View style={styles.navBar}>
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}> <Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
{Platform.OS === "ios" ? ( <View style={[StyleSheet.absoluteFill, { backgroundColor: colors.background }]} />
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
) : (
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
)}
</Animated.View> </Animated.View>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}> <TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
@@ -440,8 +443,87 @@ const styles = StyleSheet.create({
navBarBackground: { navBarBackground: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: 'rgba(233, 69, 96, 0.15)', borderBottomColor: colors.border,
overflow: "hidden", overflow: "hidden",
backgroundColor: colors.background, // Flat
},
// ...
// ...
premiumBanner: {
marginHorizontal: spacing.lg,
marginBottom: spacing.lg,
borderRadius: radius.md,
overflow: 'hidden',
// Removed heavy shadow
borderWidth: 1,
borderColor: colors.premium.gold,
},
// ...
iconWrapper: {
position: 'relative',
marginBottom: spacing.md,
// Removed glowing background elements
},
// ...
iconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: colors.backgroundLight,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
},
iconContainerPremium: {
borderColor: colors.premium.gold,
borderWidth: 2,
backgroundColor: colors.backgroundLight,
// Removed heavy shadows
},
// ...
statsBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
},
// ...
questionCard: {
backgroundColor: colors.backgroundLight, // Flat
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.sm,
},
// ...
partnerTextContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginBottom: spacing.sm,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
backgroundColor: colors.background, // Nested flat container
borderRadius: radius.sm,
alignSelf: 'flex-start',
borderWidth: 1,
borderColor: colors.border,
},
// ...
tooltipContainer: {
maxWidth: 320,
overflow: 'hidden',
backgroundColor: colors.backgroundLight, // Flat
borderWidth: 1,
borderColor: colors.border,
}, },
backButton: { backButton: {
zIndex: 10, zIndex: 10,
@@ -482,17 +564,7 @@ const styles = StyleSheet.create({
}, },
// Premium Banner // Premium Banner
premiumBanner: {
marginHorizontal: spacing.lg,
marginBottom: spacing.lg,
borderRadius: radius.md,
overflow: 'hidden',
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
premiumBannerContent: { premiumBannerContent: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -519,49 +591,32 @@ const styles = StyleSheet.create({
labelPremium: { labelPremium: {
color: colors.premium.gold, color: colors.premium.gold,
}, },
iconWrapper: { explicitBadge: {
position: 'relative', flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.error,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
gap: spacing.xs,
marginBottom: spacing.md, marginBottom: spacing.md,
}, },
explicitBadgeText: {
...typography.caption2,
fontWeight: '700',
color: colors.text,
letterSpacing: 1,
},
// Removed glow styles
iconGlowOuter: { iconGlowOuter: {
position: 'absolute', display: 'none',
top: -20,
left: -20,
right: -20,
bottom: -20,
borderRadius: 60,
backgroundColor: 'rgba(212, 175, 55, 0.08)',
}, },
iconGlow: { iconGlow: {
position: 'absolute', display: 'none',
top: -10,
left: -10,
right: -10,
bottom: -10,
borderRadius: 50,
backgroundColor: 'rgba(212, 175, 55, 0.2)',
},
iconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(255, 255, 255, 0.06)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
overflow: 'hidden',
},
iconContainerPremium: {
borderColor: colors.premium.gold,
borderWidth: 2,
backgroundColor: 'rgba(212, 175, 55, 0.15)',
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.5,
shadowRadius: 12,
elevation: 8,
}, },
crownBadge: { crownBadge: {
position: 'absolute', position: 'absolute',
top: -4, top: -4,
@@ -647,17 +702,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
marginBottom: spacing.md, marginBottom: spacing.md,
}, },
statsBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: 'rgba(233, 69, 96, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.2)',
},
statsBadgePremium: { statsBadgePremium: {
backgroundColor: 'rgba(212, 175, 55, 0.1)', backgroundColor: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.3)', borderColor: 'rgba(212, 175, 55, 0.3)',
@@ -676,13 +721,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingBottom: Platform.OS === 'ios' ? 100 : 80, paddingBottom: Platform.OS === 'ios' ? 100 : 80,
}, },
questionCard: {
backgroundColor: 'rgba(22, 33, 62, 0.4)',
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.06)',
marginBottom: spacing.sm,
},
questionContent: { questionContent: {
padding: spacing.md, padding: spacing.md,
}, },
@@ -697,17 +736,7 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm, marginBottom: spacing.sm,
lineHeight: 22, lineHeight: 22,
}, },
partnerTextContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginBottom: spacing.sm,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
backgroundColor: 'rgba(232, 164, 174, 0.1)',
borderRadius: radius.sm,
alignSelf: 'flex-start',
},
partnerText: { partnerText: {
...typography.caption1, ...typography.caption1,
color: colors.premium.rose, color: colors.premium.rose,
@@ -755,10 +784,7 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
padding: spacing.lg, padding: spacing.lg,
}, },
tooltipContainer: {
maxWidth: 320,
overflow: 'hidden',
},
tooltipTitle: { tooltipTitle: {
...typography.title3, ...typography.title3,
color: colors.text, color: colors.text,
+31
View File
@@ -300,6 +300,8 @@ PODS:
- Yoga - Yoga
- ExpoSecureStore (14.0.1): - ExpoSecureStore (14.0.1):
- ExpoModulesCore - ExpoModulesCore
- ExpoSharing (14.0.8):
- ExpoModulesCore
- EXUpdatesInterface (1.0.0): - EXUpdatesInterface (1.0.0):
- ExpoModulesCore - ExpoModulesCore
- fast_float (6.1.4) - fast_float (6.1.4)
@@ -1703,6 +1705,27 @@ PODS:
- Yoga - Yoga
- react-native-safe-area-context (4.12.0): - react-native-safe-area-context (4.12.0):
- React-Core - React-Core
- react-native-view-shot (4.0.3):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2024.10.14.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-NativeModulesApple
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- React-nativeconfig (0.76.9) - React-nativeconfig (0.76.9)
- React-NativeModulesApple (0.76.9): - React-NativeModulesApple (0.76.9):
- glog - glog
@@ -2230,6 +2253,7 @@ DEPENDENCIES:
- ExpoMediaLibrary (from `../../../node_modules/expo-media-library/ios`) - ExpoMediaLibrary (from `../../../node_modules/expo-media-library/ios`)
- ExpoModulesCore (from `../../../node_modules/expo-modules-core`) - ExpoModulesCore (from `../../../node_modules/expo-modules-core`)
- ExpoSecureStore (from `../../../node_modules/expo-secure-store/ios`) - ExpoSecureStore (from `../../../node_modules/expo-secure-store/ios`)
- ExpoSharing (from `../../../node_modules/expo-sharing/ios`)
- EXUpdatesInterface (from `../../../node_modules/expo-updates-interface/ios`) - EXUpdatesInterface (from `../../../node_modules/expo-updates-interface/ios`)
- fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`) - fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`)
- FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`)
@@ -2269,6 +2293,7 @@ DEPENDENCIES:
- React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- react-native-compressor (from `../../../node_modules/react-native-compressor`) - react-native-compressor (from `../../../node_modules/react-native-compressor`)
- react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`) - react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
- react-native-view-shot (from `../../../node_modules/react-native-view-shot`)
- React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`) - React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`)
- React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- React-perflogger (from `../../../node_modules/react-native/ReactCommon/reactperflogger`) - React-perflogger (from `../../../node_modules/react-native/ReactCommon/reactperflogger`)
@@ -2399,6 +2424,8 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/expo-modules-core" :path: "../../../node_modules/expo-modules-core"
ExpoSecureStore: ExpoSecureStore:
:path: "../../../node_modules/expo-secure-store/ios" :path: "../../../node_modules/expo-secure-store/ios"
ExpoSharing:
:path: "../../../node_modules/expo-sharing/ios"
EXUpdatesInterface: EXUpdatesInterface:
:path: "../../../node_modules/expo-updates-interface/ios" :path: "../../../node_modules/expo-updates-interface/ios"
fast_float: fast_float:
@@ -2474,6 +2501,8 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/react-native-compressor" :path: "../../../node_modules/react-native-compressor"
react-native-safe-area-context: react-native-safe-area-context:
:path: "../../../node_modules/react-native-safe-area-context" :path: "../../../node_modules/react-native-safe-area-context"
react-native-view-shot:
:path: "../../../node_modules/react-native-view-shot"
React-nativeconfig: React-nativeconfig:
:path: "../../../node_modules/react-native/ReactCommon" :path: "../../../node_modules/react-native/ReactCommon"
React-NativeModulesApple: React-NativeModulesApple:
@@ -2586,6 +2615,7 @@ SPEC CHECKSUMS:
ExpoMediaLibrary: 2709fc2e4fcf87d28767714abcc603c4bec228c6 ExpoMediaLibrary: 2709fc2e4fcf87d28767714abcc603c4bec228c6
ExpoModulesCore: 2d8d1c8e238bd5e1448b06c284ac896b162fb856 ExpoModulesCore: 2d8d1c8e238bd5e1448b06c284ac896b162fb856
ExpoSecureStore: 9a3665d7161dd1031be386e278bffd623658f316 ExpoSecureStore: 9a3665d7161dd1031be386e278bffd623658f316
ExpoSharing: 0d983394ed4a80334bab5a0d5384f75710feb7e8
EXUpdatesInterface: 7c977640bdd8b85833c19e3959ba46145c5719db EXUpdatesInterface: 7c977640bdd8b85833c19e3959ba46145c5719db
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45 FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45
@@ -2638,6 +2668,7 @@ SPEC CHECKSUMS:
React-microtasksnativemodule: 338bfa0a7d8cd1ed212b055f2143a6789252137a React-microtasksnativemodule: 338bfa0a7d8cd1ed212b055f2143a6789252137a
react-native-compressor: 40b3e2b942aebac3daaae9549b23a13510c67a6f react-native-compressor: 40b3e2b942aebac3daaae9549b23a13510c67a6f
react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448 react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448
react-native-view-shot: 4147b714c5316d7e17ed652d0b5a182c028f842e
React-nativeconfig: 415626a63057638759bcc75e0a96e2e07771a479 React-nativeconfig: 415626a63057638759bcc75e0a96e2e07771a479
React-NativeModulesApple: d33b55553c6957ff94835574636838d78121a1c6 React-NativeModulesApple: d33b55553c6957ff94835574636838d78121a1c6
React-perflogger: 72e653eb3aba9122f9e57cf012d22d2486f33358 React-perflogger: 72e653eb3aba9122f9e57cf012d22d2486f33358
+39 -39
View File
@@ -10,13 +10,13 @@
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; }; 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
205B32B3C49E4FAE89D6C5F0 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCF3A9B29424BA98FBA8B48 /* noop-file.swift */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
42547B8429A622FDB6A08D1A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6F16AA977A27D49DC4EABEBD /* PrivacyInfo.xcprivacy */; }; 6576698ADC99475D84AFD6D9 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = E4DB794D33A243BDB0648CC7 /* GoogleService-Info.plist */; };
944B75281101CC74377D82DB /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 8D7E553B42EBF30261ADEC30 /* PrivacyInfo.xcprivacy */; };
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; }; B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
B7C05C8A003645F0BE64907E /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 635D1AC9C28D4D029F2F8C7A /* GoogleService-Info.plist */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
C3F98487868740D8A0D61E28 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC04D93C99ED45B8A9943233 /* noop-file.swift */; }; BCF95FBAB579F4031AACC822 /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 95792FE2B2AD39D770B9A7F1 /* Pods_Sauci.framework */; };
FE422ED79FD52F74C1E29BFB /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8641A69B4B5E9431AF468A7C /* Pods_Sauci.framework */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -26,15 +26,15 @@
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; }; 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; };
635D1AC9C28D4D029F2F8C7A /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = "<group>"; };
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; }; 6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; };
6F16AA977A27D49DC4EABEBD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; 78D1B9CD5B66459A822DF0D3 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; }; 7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; };
8641A69B4B5E9431AF468A7C /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 8D7E553B42EBF30261ADEC30 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
95792FE2B2AD39D770B9A7F1 /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
CA157390B5A149C0AD0B6B08 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; }; CCCF3A9B29424BA98FBA8B48 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
DC04D93C99ED45B8A9943233 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; }; E4DB794D33A243BDB0648CC7 /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; }; FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -44,7 +44,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
FE422ED79FD52F74C1E29BFB /* Pods_Sauci.framework in Frameworks */, BCF95FBAB579F4031AACC822 /* Pods_Sauci.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -61,10 +61,10 @@
13B07FB61A68108700A75B9A /* Info.plist */, 13B07FB61A68108700A75B9A /* Info.plist */,
13B07FB71A68108700A75B9A /* main.m */, 13B07FB71A68108700A75B9A /* main.m */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
635D1AC9C28D4D029F2F8C7A /* GoogleService-Info.plist */, E4DB794D33A243BDB0648CC7 /* GoogleService-Info.plist */,
DC04D93C99ED45B8A9943233 /* noop-file.swift */, CCCF3A9B29424BA98FBA8B48 /* noop-file.swift */,
CA157390B5A149C0AD0B6B08 /* Sauci-Bridging-Header.h */, 78D1B9CD5B66459A822DF0D3 /* Sauci-Bridging-Header.h */,
6F16AA977A27D49DC4EABEBD /* PrivacyInfo.xcprivacy */, 8D7E553B42EBF30261ADEC30 /* PrivacyInfo.xcprivacy */,
); );
name = Sauci; name = Sauci;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -73,7 +73,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */, ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
8641A69B4B5E9431AF468A7C /* Pods_Sauci.framework */, 95792FE2B2AD39D770B9A7F1 /* Pods_Sauci.framework */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -150,15 +150,15 @@
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */;
buildPhases = ( buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
380EBB147031A50785146268 /* [Expo] Configure project */, 343E25507BEB6EFEF50B08AF /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */, 13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */, 13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
5F6BF0C2EF824875B30C4F47 /* Upload Debug Symbols to Sentry */, 8C453F47518C4580A321EB65 /* Upload Debug Symbols to Sentry */,
83F7C8F647047F466249E15C /* [CP] Embed Pods Frameworks */, 9F907B34B4D68CFE07BD67A0 /* [CP] Embed Pods Frameworks */,
3AB0FB698F7BE8E7FEAC6EB4 /* [CP-User] [RNFB] Core Configuration */, 5020657B8213723F010419B9 /* [CP-User] [RNFB] Core Configuration */,
); );
buildRules = ( buildRules = (
); );
@@ -210,8 +210,8 @@
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
B7C05C8A003645F0BE64907E /* GoogleService-Info.plist in Resources */, 6576698ADC99475D84AFD6D9 /* GoogleService-Info.plist in Resources */,
42547B8429A622FDB6A08D1A /* PrivacyInfo.xcprivacy in Resources */, 944B75281101CC74377D82DB /* PrivacyInfo.xcprivacy in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -255,7 +255,7 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
380EBB147031A50785146268 /* [Expo] Configure project */ = { 343E25507BEB6EFEF50B08AF /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -274,7 +274,7 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n"; shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n";
}; };
3AB0FB698F7BE8E7FEAC6EB4 /* [CP-User] [RNFB] Core Configuration */ = { 5020657B8213723F010419B9 /* [CP-User] [RNFB] Core Configuration */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -287,20 +287,6 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"note: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"note: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"note: -> RNFB build script started\"\necho \"note: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"note: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"note: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n if ! _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\"); then\n echo \"error: Failed to parse firebase.json, check for syntax errors.\"\n exit 1\n fi\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"error: python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACLine truncated shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"note: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"note: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"note: -> RNFB build script started\"\necho \"note: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"note: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"note: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n if ! _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\"); then\n echo \"error: Failed to parse firebase.json, check for syntax errors.\"\n exit 1\n fi\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"error: python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACLine truncated
}; };
5F6BF0C2EF824875B30C4F47 /* Upload Debug Symbols to Sentry */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Upload Debug Symbols to Sentry";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
};
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = { 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -373,7 +359,21 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
83F7C8F647047F466249E15C /* [CP] Embed Pods Frameworks */ = { 8C453F47518C4580A321EB65 /* Upload Debug Symbols to Sentry */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Upload Debug Symbols to Sentry";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
};
9F907B34B4D68CFE07BD67A0 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -401,7 +401,7 @@
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */, 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */, 13B07FC11A68108700A75B9A /* main.m in Sources */,
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */, B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
C3F98487868740D8A0D61E28 /* noop-file.swift in Sources */, 205B32B3C49E4FAE89D6C5F0 /* noop-file.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+4
View File
@@ -54,6 +54,7 @@
"expo-notifications": "~0.29.0", "expo-notifications": "~0.29.0",
"expo-router": "~4.0.0", "expo-router": "~4.0.0",
"expo-secure-store": "~14.0.0", "expo-secure-store": "~14.0.0",
"expo-sharing": "^14.0.8",
"expo-status-bar": "~2.0.0", "expo-status-bar": "~2.0.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
@@ -66,6 +67,7 @@
"react-native-screens": "~4.4.0", "react-native-screens": "~4.4.0",
"react-native-svg": "15.8.0", "react-native-svg": "15.8.0",
"react-native-url-polyfill": "^2.0.0", "react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "^4.0.3",
"react-native-web": "~0.19.13", "react-native-web": "~0.19.13",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },
@@ -77,6 +79,8 @@
"@types/react": "~18.3.12", "@types/react": "~18.3.12",
"@types/react-test-renderer": "^19.1.0", "@types/react-test-renderer": "^19.1.0",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"eslint": "^8.57.0",
"eslint-config-expo": "~8.0.1",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-expo": "~52.0.0", "jest-expo": "~52.0.0",
"react-test-renderer": "^18.3.1", "react-test-renderer": "^18.3.1",
@@ -72,22 +72,21 @@ describe('GlassCard', () => {
}); });
describe('platform behavior', () => { describe('platform behavior', () => {
it('uses BlurView on iOS', () => { // GlassCard now uses solid backgrounds instead of BlurView for reliability
it('renders correctly on iOS', () => {
Object.defineProperty(Platform, 'OS', { Object.defineProperty(Platform, 'OS', {
get: () => 'ios', get: () => 'ios',
}); });
const { getByTestId } = render( const { getByText } = render(
<GlassCard> <GlassCard>
<Text>iOS Card</Text> <Text>iOS Card</Text>
</GlassCard> </GlassCard>
); );
expect(getByTestId('blur-view')).toBeTruthy(); expect(getByText('iOS Card')).toBeTruthy();
}); });
// Note: Testing Android platform requires component re-evaluation
// which is complex with module-level Platform checks
it('renders correctly', () => { it('renders correctly', () => {
const { getByText } = render( const { getByText } = render(
<GlassCard> <GlassCard>
@@ -100,43 +99,42 @@ describe('GlassCard', () => {
}); });
describe('intensity prop', () => { describe('intensity prop', () => {
// Note: intensity prop is kept for API compatibility but no longer affects visual rendering
// since BlurView was replaced with solid backgrounds for reliability
beforeEach(() => { beforeEach(() => {
Object.defineProperty(Platform, 'OS', { Object.defineProperty(Platform, 'OS', {
get: () => 'ios', get: () => 'ios',
}); });
}); });
it('applies light intensity', () => { it('accepts light intensity prop', () => {
const { getByTestId } = render( const { getByText } = render(
<GlassCard intensity="light"> <GlassCard intensity="light">
<Text>Light Card</Text> <Text>Light Card</Text>
</GlassCard> </GlassCard>
); );
const blurView = getByTestId('blur-view'); expect(getByText('Light Card')).toBeTruthy();
expect(blurView).toBeTruthy();
}); });
it('applies medium intensity by default', () => { it('accepts medium intensity by default', () => {
const { getByTestId } = render( const { getByText } = render(
<GlassCard> <GlassCard>
<Text>Medium Card</Text> <Text>Medium Card</Text>
</GlassCard> </GlassCard>
); );
const blurView = getByTestId('blur-view'); expect(getByText('Medium Card')).toBeTruthy();
expect(blurView).toBeTruthy();
}); });
it('applies heavy intensity', () => { it('accepts heavy intensity prop', () => {
const { getByTestId } = render( const { getByText } = render(
<GlassCard intensity="heavy"> <GlassCard intensity="heavy">
<Text>Heavy Card</Text> <Text>Heavy Card</Text>
</GlassCard> </GlassCard>
); );
const blurView = getByTestId('blur-view'); expect(getByText('Heavy Card')).toBeTruthy();
expect(blurView).toBeTruthy();
}); });
}); });
@@ -92,16 +92,17 @@ describe('GlassInput', () => {
}); });
describe('platform behavior', () => { describe('platform behavior', () => {
it('uses BlurView on iOS', () => { // GlassInput now uses solid backgrounds instead of BlurView for reliability
it('renders correctly on iOS', () => {
Object.defineProperty(Platform, 'OS', { Object.defineProperty(Platform, 'OS', {
get: () => 'ios', get: () => 'ios',
}); });
const { getByTestId } = render( const { getByPlaceholderText } = render(
<GlassInput placeholder="iOS Input" /> <GlassInput placeholder="iOS Input" />
); );
expect(getByTestId('blur-view')).toBeTruthy(); expect(getByPlaceholderText('iOS Input')).toBeTruthy();
}); });
// Note: Testing Android platform requires component re-evaluation // Note: Testing Android platform requires component re-evaluation
+40 -21
View File
@@ -71,33 +71,39 @@ export function BoutiquePackCard({
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
/> />
{/* Glass overlay */} {/* Flat overlay - removed glass */}
{useBlur ? ( <View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(0,0,0,0.4)' }]} />
<BlurView
intensity={blur.light}
tint="dark"
style={[StyleSheet.absoluteFill, styles.glassOverlay]}
/>
) : (
<View style={[StyleSheet.absoluteFill, styles.glassOverlayFallback]} />
)}
{/* Silk texture effect */} {/* Silk texture effect - removed for flatter look
<LinearGradient <LinearGradient
colors={gradients.silkLight as [string, string]} colors={gradients.silkLight as [string, string]}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
style={[StyleSheet.absoluteFill, { opacity: 0.5 }]} style={[StyleSheet.absoluteFill, { opacity: 0.5 }]}
/> />
*/}
{/* Top highlight */} {/* Top highlight - removed
<LinearGradient <LinearGradient
colors={[colors.glass.highlight, 'transparent']} colors={[colors.glass.highlight, 'transparent']}
style={styles.topHighlight} style={styles.topHighlight}
/> />
*/}
{/* Content */} {/* Content */}
<View style={styles.content}> <View style={styles.content}>
{/* Explicit Badge (top-left) */}
{pack.is_explicit && (
<View style={styles.explicitBadge}>
<Ionicons
name="flame"
size={10}
color={colors.text}
/>
<Text style={styles.explicitBadgeText}>18+</Text>
</View>
)}
{/* PRO Badge (top-right) */} {/* PRO Badge (top-right) */}
{pack.is_premium && ( {pack.is_premium && (
<View style={[ <View style={[
@@ -209,7 +215,6 @@ const styles = StyleSheet.create({
cardPremium: { cardPremium: {
borderWidth: 1, borderWidth: 1,
borderColor: colors.premium.gold, borderColor: colors.premium.gold,
...shadows.glow(colors.premium.goldGlow),
}, },
cardEnabled: { cardEnabled: {
borderWidth: 1, borderWidth: 1,
@@ -224,23 +229,37 @@ const styles = StyleSheet.create({
overflow: 'hidden', overflow: 'hidden',
}, },
glassOverlay: { glassOverlay: {
backgroundColor: 'rgba(22, 33, 62, 0.3)', display: 'none',
}, },
glassOverlayFallback: { glassOverlayFallback: {
backgroundColor: 'rgba(22, 33, 62, 0.5)', display: 'none',
}, },
topHighlight: { topHighlight: {
position: 'absolute', display: 'none',
top: 0,
left: 0,
right: 0,
height: 40,
}, },
content: { content: {
flex: 1, flex: 1,
padding: spacing.md, padding: spacing.md,
justifyContent: 'space-between', justifyContent: 'space-between',
}, },
explicitBadge: {
position: 'absolute',
top: spacing.sm,
left: spacing.sm,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.error,
paddingHorizontal: spacing.sm,
paddingVertical: 3,
borderRadius: radius.xs,
gap: 3,
zIndex: 10,
},
explicitBadgeText: {
...typography.caption2,
color: colors.text,
fontWeight: '700',
},
proBadge: { proBadge: {
position: 'absolute', position: 'absolute',
top: spacing.sm, top: spacing.sm,
@@ -255,7 +274,7 @@ const styles = StyleSheet.create({
zIndex: 10, zIndex: 10,
}, },
proBadgeLocked: { proBadgeLocked: {
backgroundColor: colors.glass.backgroundLight, backgroundColor: colors.background, // Flat bg
}, },
proBadgeText: { proBadgeText: {
...typography.caption2, ...typography.caption2,
@@ -0,0 +1,360 @@
/**
* MatchConfetti - Premium celebration animation for match moments
*
* Uses Reanimated for smooth animations. Displays an elegant burst of
* hearts and sparkles that explode outward, plus a prominent "It's a Match!" popup.
*/
import React, { useEffect, useMemo } from 'react';
import { View, StyleSheet, Dimensions, Text } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withDelay,
withSpring,
withSequence,
Easing,
interpolate,
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import { Platform } from 'react-native';
import { colors, typography, spacing, radius } from '../theme';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
interface Props {
visible: boolean;
onAnimationComplete?: () => void;
}
type ParticleShape = 'heart' | 'sparkle' | 'circle' | 'star';
interface Particle {
id: number;
startX: number;
startY: number;
endX: number;
endY: number;
rotation: number;
color: string;
size: number;
delay: number;
shape: ParticleShape;
}
const CONFETTI_COLORS = [
colors.primary, // Brand pink #e1306c
'#FF6B9D', // Hot pink
'#FFB3C6', // Light pink
colors.premium.rose, // Soft rose
'#9b59b6', // Secondary purple
colors.premium.gold, // Gold accent
'#FF8A5B', // Coral
'#FFFFFF', // White sparkle
];
const PARTICLE_COUNT = 40;
// Particle shapes distribution
const SHAPES: ParticleShape[] = ['heart', 'heart', 'sparkle', 'sparkle', 'circle', 'star'];
// Generate particle with physics-based trajectory
const generateParticle = (index: number): Particle => {
// Start from center of screen
const startX = SCREEN_WIDTH / 2;
const startY = SCREEN_HEIGHT / 2 - 80;
// Random angle for explosion (360 degrees) with more upward bias
const baseAngle = (index / PARTICLE_COUNT) * Math.PI * 2;
const angle = baseAngle + (Math.random() - 0.5) * 0.8;
const velocity = 180 + Math.random() * 280;
// Calculate end position - more spread, less gravity drop for hearts
const endX = startX + Math.cos(angle) * velocity;
const endY = startY + Math.sin(angle) * velocity + 250;
const shape = SHAPES[Math.floor(Math.random() * SHAPES.length)];
return {
id: index,
startX,
startY,
endX,
endY,
rotation: Math.random() * 540 - 270,
color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
size: shape === 'heart' ? 14 + Math.random() * 10 : 8 + Math.random() * 8,
delay: Math.random() * 150,
shape,
};
};
// Heart shape component
const HeartShape: React.FC<{ size: number; color: string }> = ({ size, color }) => (
<Ionicons name="heart" size={size} color={color} />
);
// Sparkle/star shape component
const SparkleShape: React.FC<{ size: number; color: string }> = ({ size, color }) => (
<Ionicons name="sparkles" size={size} color={color} />
);
// Star shape component
const StarShape: React.FC<{ size: number; color: string }> = ({ size, color }) => (
<Ionicons name="star" size={size} color={color} />
);
const ConfettiParticle: React.FC<{ particle: Particle; visible: boolean }> = ({
particle,
visible,
}) => {
const progress = useSharedValue(0);
const opacity = useSharedValue(0);
useEffect(() => {
if (visible) {
progress.value = 0;
opacity.value = 1;
progress.value = withDelay(
particle.delay,
withTiming(1, {
duration: 1000,
easing: Easing.out(Easing.cubic),
})
);
opacity.value = withDelay(
particle.delay + 600,
withTiming(0, {
duration: 400,
easing: Easing.out(Easing.ease),
})
);
} else {
progress.value = 0;
opacity.value = 0;
}
}, [visible]);
const animatedStyle = useAnimatedStyle(() => {
const x = particle.startX + (particle.endX - particle.startX) * progress.value;
const y = particle.startY + (particle.endY - particle.startY) * progress.value;
const rotation = particle.rotation * progress.value;
// Scale pulses slightly then shrinks
const scale = interpolate(progress.value, [0, 0.2, 1], [0.5, 1.2, 0.6]);
return {
transform: [
{ translateX: x },
{ translateY: y },
{ rotate: `${rotation}deg` },
{ scale },
],
opacity: opacity.value,
};
});
const renderShape = () => {
switch (particle.shape) {
case 'heart':
return <HeartShape size={particle.size} color={particle.color} />;
case 'sparkle':
return <SparkleShape size={particle.size} color={particle.color} />;
case 'star':
return <StarShape size={particle.size} color={particle.color} />;
case 'circle':
default:
return (
<View
style={{
width: particle.size,
height: particle.size,
backgroundColor: particle.color,
borderRadius: particle.size / 2,
}}
/>
);
}
};
return (
<Animated.View style={[styles.particle, animatedStyle]}>
{renderShape()}
</Animated.View>
);
};
// Animated popup message component - prominent and celebratory
const MatchPopup: React.FC<{ visible: boolean }> = ({ visible }) => {
const scale = useSharedValue(0);
const opacity = useSharedValue(0);
const iconScale = useSharedValue(0);
useEffect(() => {
if (visible) {
// Pop in with bouncy spring, then fade out after delay
scale.value = withSequence(
withSpring(1, { damping: 10, stiffness: 180 }),
withDelay(3200, withTiming(0.9, { duration: 400 }))
);
// Fade in quickly, stay visible, then fade out
opacity.value = withSequence(
withTiming(1, { duration: 150 }),
withDelay(3200, withTiming(0, { duration: 400 }))
);
// Heart icon bounces after text appears
iconScale.value = withDelay(
150,
withSequence(
withSpring(1.3, { damping: 8, stiffness: 200 }),
withSpring(1, { damping: 12, stiffness: 200 })
)
);
} else {
scale.value = 0;
opacity.value = 0;
iconScale.value = 0;
}
}, [visible]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: opacity.value,
}));
const iconAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: iconScale.value }],
}));
return (
<Animated.View style={[styles.popupContainer, animatedStyle]}>
<View style={styles.popup}>
<LinearGradient
colors={[colors.primary, '#9b59b6']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.popupGradient}
>
<Text style={styles.popupTitle}>You both answered!</Text>
<Animated.View style={iconAnimatedStyle}>
<Ionicons name="heart" size={32} color="#fff" style={styles.popupIcon} />
</Animated.View>
</LinearGradient>
</View>
</Animated.View>
);
};
export const MatchConfetti: React.FC<Props> = ({ visible, onAnimationComplete }) => {
const particles = useMemo(
() => Array.from({ length: PARTICLE_COUNT }, (_, i) => generateParticle(i)),
[]
);
// Trigger haptic and callback when animation starts
useEffect(() => {
if (visible) {
// Celebration haptic pattern - satisfying burst
if (Platform.OS !== 'web') {
// Initial success notification
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Follow up with impacts for celebration feel
setTimeout(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
}, 80);
setTimeout(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}, 180);
setTimeout(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
}, 300);
}
// Notify parent when animation completes (after popup fades)
if (onAnimationComplete) {
const timer = setTimeout(() => {
onAnimationComplete();
}, 4000);
return () => clearTimeout(timer);
}
}
}, [visible, onAnimationComplete]);
if (!visible) return null;
return (
<View style={styles.container} pointerEvents="none">
{/* Confetti particles */}
{particles.map((particle) => (
<ConfettiParticle
key={particle.id}
particle={particle}
visible={visible}
/>
))}
{/* Match popup message */}
<MatchPopup visible={visible} />
</View>
);
};
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
zIndex: 1000,
},
particle: {
position: 'absolute',
left: 0,
top: 0,
},
popupContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
popup: {
borderRadius: radius.xl,
shadowColor: colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.6,
shadowRadius: 24,
elevation: 15,
overflow: 'hidden',
},
popupGradient: {
paddingHorizontal: spacing.xl + spacing.md,
paddingVertical: spacing.lg + spacing.sm,
alignItems: 'center',
},
popupTitle: {
...typography.title1,
color: '#fff',
fontWeight: '800',
textAlign: 'center',
textShadowColor: 'rgba(0, 0, 0, 0.3)',
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
},
popupSubtext: {
...typography.callout,
color: 'rgba(255, 255, 255, 0.9)',
textAlign: 'center',
marginTop: spacing.xs,
},
popupIcon: {
marginTop: spacing.sm,
},
});
@@ -252,14 +252,8 @@ export const MatchNotificationModal: React.FC<Props> = ({
<View style={styles.glowOuter} /> <View style={styles.glowOuter} />
<View style={styles.modalContainer}> <View style={styles.modalContainer}>
{/* Glass background */} {/* Flat background */}
<BlurView intensity={40} tint="dark" style={styles.blurContainer}> <View style={styles.flatBackground} />
<LinearGradient
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
style={styles.gradientOverlay}
/>
</BlurView>
{/* Content */} {/* Content */}
<View style={styles.content}> <View style={styles.content}>
{/* Title section */} {/* Title section */}
@@ -342,9 +336,6 @@ export const MatchNotificationModal: React.FC<Props> = ({
</Pressable> </Pressable>
</View> </View>
</View> </View>
{/* Border */}
<View style={styles.border} pointerEvents="none" />
</View> </View>
</Animated.View> </Animated.View>
</Animated.View> </Animated.View>
@@ -374,24 +365,22 @@ const styles = StyleSheet.create({
borderRadius: radius.xl + 3, borderRadius: radius.xl + 3,
backgroundColor: 'transparent', backgroundColor: 'transparent',
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.15)', borderColor: colors.primary, // Simpler border
shadowColor: colors.primary, opacity: 0.2, // Subtle opacity
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 8,
}, },
modalContainer: { modalContainer: {
width: '100%', width: '100%',
borderRadius: radius.xl, borderRadius: radius.xl,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: colors.backgroundLight, // Solid background
borderWidth: 1,
borderColor: colors.border,
}, },
blurContainer: { flatBackground: {
...StyleSheet.absoluteFillObject,
},
gradientOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: colors.backgroundLight,
}, },
// Removed blur/gradient styles
content: { content: {
padding: spacing.xl, padding: spacing.xl,
paddingTop: spacing.lg, paddingTop: spacing.lg,
@@ -404,7 +393,7 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm, marginBottom: spacing.sm,
}, },
matchTypeBadge: { matchTypeBadge: {
backgroundColor: 'rgba(255, 255, 255, 0.05)', backgroundColor: colors.background, // Darker flat background
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
borderRadius: radius.full, borderRadius: radius.full,
@@ -523,21 +512,17 @@ const styles = StyleSheet.create({
dismissButton: { dismissButton: {
flex: 1, flex: 1,
borderRadius: radius.lg, borderRadius: radius.lg,
backgroundColor: colors.glass.background, backgroundColor: colors.background, // Flat dark background
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.border, borderColor: colors.border,
paddingVertical: spacing.md, paddingVertical: spacing.md,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
// Removed border style since it's now on modalContainer
// border: { ... }
dismissButtonText: { dismissButtonText: {
...typography.headline, ...typography.headline,
color: colors.textSecondary, color: colors.textSecondary,
}, },
border: {
...StyleSheet.absoluteFillObject,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.2)',
},
}); });
@@ -11,25 +11,39 @@ interface CompactHeaderProps {
user: Profile | null; user: Profile | null;
partner: Profile | null; partner: Profile | null;
couple: Couple | null; couple: Couple | null;
label?: string;
accessory?: React.ReactNode;
showGreeting?: boolean;
showPartnerBadge?: boolean;
} }
export function CompactHeader({ user, partner, couple }: CompactHeaderProps) { export function CompactHeader({
user,
partner,
couple,
label = 'Discover',
accessory,
showGreeting = true,
showPartnerBadge = true,
}: CompactHeaderProps) {
return ( return (
<Animated.View <Animated.View
entering={FadeInDown.delay(100).duration(500).springify()} entering={FadeInDown.delay(100).duration(500).springify()}
style={styles.container} style={styles.container}
> >
{/* Greeting */} {showGreeting ? (
<View style={styles.greetingRow}> <View style={styles.greetingRow}>
<View> <View>
<Text style={styles.welcomeLabel}>Discover</Text> <Text style={styles.welcomeLabel}>{label}</Text>
<Text style={styles.greeting}> <Text style={styles.greeting}>
Hey, {user?.name || 'Beautiful'} Hey, {user?.name || 'Beautiful'}
</Text> </Text>
</View> </View>
<View style={styles.rightSection}>
{/* Partner badge */} {/* Partner badge */}
{partner ? ( {showPartnerBadge ? (
partner ? (
<View style={styles.partnerBadge}> <View style={styles.partnerBadge}>
{partner.avatar_url ? ( {partner.avatar_url ? (
<Image <Image
@@ -62,8 +76,18 @@ export function CompactHeader({ user, partner, couple }: CompactHeaderProps) {
<Ionicons name="add-circle-outline" size={14} color={colors.textTertiary} /> <Ionicons name="add-circle-outline" size={14} color={colors.textTertiary} />
<Text style={styles.pairText}>Connect</Text> <Text style={styles.pairText}>Connect</Text>
</TouchableOpacity> </TouchableOpacity>
)} )
) : null}
{accessory ? <View style={styles.accessory}>{accessory}</View> : null}
</View> </View>
</View>
) : (
<View style={styles.titleRow}>
{accessory ? <View style={styles.titleSpacer} /> : null}
<Text style={styles.titleOnly}>{label}</Text>
{accessory ? <View style={styles.accessory}>{accessory}</View> : null}
</View>
)}
</Animated.View> </Animated.View>
); );
} }
@@ -79,6 +103,29 @@ const styles = StyleSheet.create({
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
}, },
rightSection: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
accessory: {
justifyContent: 'center',
alignItems: 'center',
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
titleOnly: {
...typography.title2,
color: colors.text,
textAlign: 'center',
flex: 1,
},
titleSpacer: {
width: 32,
},
welcomeLabel: { welcomeLabel: {
...typography.caption2, ...typography.caption2,
fontWeight: '600', fontWeight: '600',
@@ -32,7 +32,7 @@ export function ContentRow({
const handleSeeAll = () => { const handleSeeAll = () => {
// Navigate to packs screen with category filter // Navigate to packs screen with category filter
router.push({ router.push({
pathname: '/(app)/packs', pathname: '/',
params: categoryId ? { category: categoryId } : undefined, params: categoryId ? { category: categoryId } : undefined,
}); });
}; };
@@ -29,14 +29,13 @@ export function ContentTile({ pack, isLocked, isNew = false, progress, onPress }
}; };
const backgroundColor = getCategoryColor(pack.category); const backgroundColor = getCategoryColor(pack.category);
const icon = pack.icon || pack.category?.icon || 'layers'; const icon = pack.icon || pack.category?.icon || 'layers';
const questionCount = pack.questions?.[0]?.count ?? 0;
// Calculate progress percentage // Calculate progress percentage
const progressPercent = progress && progress.totalQuestions > 0 const progressPercent = progress && progress.totalQuestions > 0
? progress.answeredQuestions / progress.totalQuestions ? progress.answeredQuestions / progress.totalQuestions
: 0; : 0;
// Show progress ring for packs with questions (even at 0%) // Always show progress ring when progress data exists (even at 0% or 0 questions)
const showProgress = progress && progress.totalQuestions > 0; const showProgress = !!progress;
return ( return (
<TouchableOpacity <TouchableOpacity
@@ -55,6 +54,12 @@ export function ContentTile({ pack, isLocked, isNew = false, progress, onPress }
/> />
</View> </View>
<View style={styles.badges}> <View style={styles.badges}>
{pack.is_explicit && (
<View style={styles.explicitBadge}>
<Ionicons name="flame" size={10} color={colors.text} />
<Text style={styles.explicitBadgeText}>18+</Text>
</View>
)}
{isNew && ( {isNew && (
<View style={styles.newBadge}> <View style={styles.newBadge}>
<Text style={styles.newBadgeText}>NEW</Text> <Text style={styles.newBadgeText}>NEW</Text>
@@ -87,13 +92,6 @@ export function ContentTile({ pack, isLocked, isNew = false, progress, onPress }
</Text> </Text>
</View> </View>
{/* Bottom info */}
<View style={styles.bottomRow}>
<Text style={styles.countText}>
{questionCount} {questionCount === 1 ? 'question' : 'questions'}
</Text>
</View>
{/* Decorative overlay for depth */} {/* Decorative overlay for depth */}
<View style={styles.overlay} /> <View style={styles.overlay} />
</View> </View>
@@ -136,6 +134,20 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
gap: spacing.xs, gap: spacing.xs,
}, },
explicitBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.error,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.xs,
gap: 2,
},
explicitBadgeText: {
...typography.caption2,
fontWeight: '700',
color: colors.text,
},
newBadge: { newBadge: {
backgroundColor: 'rgba(255, 255, 255, 0.95)', backgroundColor: 'rgba(255, 255, 255, 0.95)',
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
@@ -173,13 +185,6 @@ const styles = StyleSheet.create({
color: colors.text, color: colors.text,
fontWeight: '700', fontWeight: '700',
}, },
bottomRow: {
marginTop: 'auto',
},
countText: {
...typography.caption1,
color: 'rgba(255, 255, 255, 0.7)',
},
}); });
export default ContentTile; export default ContentTile;
@@ -218,7 +218,7 @@ const styles = StyleSheet.create({
width: '100%', width: '100%',
maxWidth: 360, maxWidth: 360,
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.border, borderColor: colors.border,
}, },
header: { header: {
alignItems: 'center', alignItems: 'center',
@@ -238,12 +238,12 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
}, },
questionPreview: { questionPreview: {
backgroundColor: colors.glass.background, backgroundColor: colors.background, // Flat inner container
borderRadius: radius.md, borderRadius: radius.md,
padding: spacing.md, padding: spacing.md,
marginBottom: spacing.md, marginBottom: spacing.md,
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.border, borderColor: colors.border,
}, },
questionText: { questionText: {
...typography.subhead, ...typography.subhead,
@@ -267,12 +267,12 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: spacing.xs, gap: spacing.xs,
backgroundColor: colors.glass.background, backgroundColor: colors.background, // Flat button
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
borderRadius: radius.md, borderRadius: radius.md,
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.border, borderColor: colors.border,
}, },
reasonButtonActive: { reasonButtonActive: {
backgroundColor: colors.primary, backgroundColor: colors.primary,
@@ -287,10 +287,10 @@ const styles = StyleSheet.create({
color: colors.text, color: colors.text,
}, },
input: { input: {
backgroundColor: colors.glass.background, backgroundColor: colors.background,
borderRadius: radius.md, borderRadius: radius.md,
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.border, borderColor: colors.border,
padding: spacing.md, padding: spacing.md,
...typography.body, ...typography.body,
color: colors.text, color: colors.text,
@@ -303,12 +303,12 @@ const styles = StyleSheet.create({
}, },
cancelButton: { cancelButton: {
flex: 1, flex: 1,
backgroundColor: colors.glass.backgroundLight, backgroundColor: colors.background,
paddingVertical: spacing.md, paddingVertical: spacing.md,
borderRadius: radius.md, borderRadius: radius.md,
alignItems: 'center', alignItems: 'center',
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.borderLight, borderColor: colors.border,
}, },
cancelButtonText: { cancelButtonText: {
...typography.body, ...typography.body,
+196 -226
View File
@@ -10,40 +10,45 @@ import {
Platform, Platform,
Pressable, Pressable,
Linking, Linking,
Image,
} from "react-native"; } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { router } from "expo-router"; import { router } from "expo-router";
import { useAuthStore, useSubscriptionStore } from "../../store"; import { useAuthStore, useSubscriptionStore } from "../../store";
import { colors, gradients, spacing, radius, typography } from "../../theme"; import { colors, gradients, spacing, radius, typography, shadows } from "../../theme";
import type { PurchasesPackage } from "../../lib/revenuecat"; import type { PurchasesPackage } from "../../lib/revenuecat";
import { Events } from "../../lib/analytics"; import { Events } from "../../lib/analytics";
const logoImage = require("../../../assets/logo.png");
interface PaywallProps { interface PaywallProps {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
onSuccess?: () => void; onSuccess?: () => void;
/** Optional RevenueCat offering identifier. If not provided, uses the default offering. */
offeringId?: string;
} }
const FEATURES = [ const FEATURES = [
{ {
icon: "diamond" as const, icon: "layers" as const,
title: "Unlock All Packs", title: "All Premium Packs",
description: "Access every premium collection", description: "Unlock every intimate collection",
}, },
{ {
icon: "sparkles" as const, icon: "sparkles" as const,
title: "Exclusive Content", title: "New Content Weekly",
description: "New packs added regularly", description: "Fresh questions added regularly",
}, },
{ {
icon: "heart" as const, icon: "heart" as const,
title: "Share with Partner", title: "Shared Access",
description: "Both of you get Pro access", description: "Your partner gets Pro too",
}, },
]; ];
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) { export function Paywall({ visible, onClose, onSuccess, offeringId }: PaywallProps) {
const { isAnonymous } = useAuthStore(); const { isAnonymous } = useAuthStore();
const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => { const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => {
@@ -67,15 +72,13 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const [guestConfirmed, setGuestConfirmed] = useState(false); const [guestConfirmed, setGuestConfirmed] = useState(false);
useEffect(() => { useEffect(() => {
if (visible && !offerings) {
fetchOfferings();
}
if (visible) { if (visible) {
fetchOfferings(offeringId);
Events.paywallShown("paywall"); Events.paywallShown("paywall");
setGuestConfirmed(false); setGuestConfirmed(false);
setShowGuestConfirm(false); setShowGuestConfirm(false);
} }
}, [visible, offerings, fetchOfferings]); }, [visible, offeringId, fetchOfferings]);
useEffect(() => { useEffect(() => {
// Auto-select annual package as default (better value) // Auto-select annual package as default (better value)
@@ -169,18 +172,13 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
> >
<View style={styles.overlay}> <View style={styles.overlay}>
<View style={styles.content}> <View style={styles.content}>
{/* Premium gradient background */} {/* Gold accent line at top */}
<LinearGradient <LinearGradient
colors={['rgba(22, 33, 62, 0.98)', 'rgba(13, 13, 26, 1)']} colors={[colors.premium.gold, colors.premium.goldDark]}
style={StyleSheet.absoluteFill} style={styles.topAccent}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/> />
{/* Top silk highlight */}
<LinearGradient
colors={['rgba(212, 175, 55, 0.08)', 'transparent']}
style={styles.silkHighlight}
/>
{/* Premium border accent */}
<View style={styles.premiumBorderAccent} />
<ScrollView <ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
@@ -192,47 +190,34 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
onPress={() => handleClose("dismiss")} onPress={() => handleClose("dismiss")}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
> >
<Ionicons name="close" size={24} color={colors.textSecondary} /> <Ionicons name="close" size={22} color={colors.textSecondary} />
</TouchableOpacity> </TouchableOpacity>
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
{/* Premium label */} {/* Sauci logo */}
<Text style={styles.label}>EXCLUSIVE ACCESS</Text> <View style={styles.logoContainer}>
<Image
{/* Icon with glow */} source={logoImage}
<View style={styles.iconContainer}> style={styles.logo}
<LinearGradient resizeMode="contain"
colors={[colors.premium.gold, colors.premium.goldDark]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/> />
<Ionicons name="diamond" size={32} color={colors.background} />
</View> </View>
<Text style={styles.title}>Sauci Pro</Text> <Text style={styles.title}>Sauci Pro</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
Unlock all premium content for you and your partner Unlock the full intimate experience
</Text> </Text>
</View> </View>
{/* Features */} {/* Features */}
<View style={styles.features}> <View style={styles.features}>
{FEATURES.map((feature, index) => ( {FEATURES.map((feature) => (
<View key={feature.title} style={styles.featureRow}> <View key={feature.title} style={styles.featureRow}>
<View style={styles.featureIcon}> <View style={styles.featureIcon}>
<Ionicons <Ionicons
name={feature.icon} name={feature.icon}
size={18} size={20}
color={colors.premium.gold} color={colors.premium.gold}
/> />
</View> </View>
@@ -242,11 +227,6 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
{feature.description} {feature.description}
</Text> </Text>
</View> </View>
<Ionicons
name="checkmark-circle"
size={20}
color={colors.premium.gold}
/>
</View> </View>
))} ))}
</View> </View>
@@ -261,28 +241,27 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
</Text> </Text>
</View> </View>
) : offerings?.availablePackages ? ( ) : offerings?.availablePackages ? (
offerings.availablePackages.map((pkg) => { <>
const isSelected = {/* Annual package - hero card */}
selectedPackage?.identifier === pkg.identifier; {offerings.availablePackages
const isAnnual = pkg.packageType === "ANNUAL"; .filter((p) => p.packageType === "ANNUAL")
.map((pkg) => {
const isSelected = selectedPackage?.identifier === pkg.identifier;
return ( return (
<Pressable <Pressable
key={pkg.identifier} key={pkg.identifier}
onPress={() => setSelectedPackage(pkg)} onPress={() => setSelectedPackage(pkg)}
style={[ style={[
styles.packageCard, styles.packageCard,
styles.packageCardAnnual,
isSelected && styles.packageCardSelected, isSelected && styles.packageCardSelected,
isAnnual && styles.packageCardAnnual,
]} ]}
> >
{isAnnual && ( {/* Subtle gold tint background */}
<LinearGradient <View style={styles.annualBackground} />
colors={['rgba(212, 175, 55, 0.1)', 'rgba(184, 134, 11, 0.05)']}
style={StyleSheet.absoluteFill} {/* Best value badge */}
/> {savings && savings > 0 && (
)}
{isAnnual && savings && savings > 0 && (
<View style={styles.savingsBadge}> <View style={styles.savingsBadge}>
<LinearGradient <LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]} colors={[colors.premium.gold, colors.premium.goldDark]}
@@ -290,46 +269,68 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
/> />
<Ionicons name="star" size={10} color={colors.background} />
<Text style={styles.savingsText}> <Text style={styles.savingsText}>
SAVE {savings}% SAVE {savings}%
</Text> </Text>
</View> </View>
)} )}
<View style={styles.packageContent}> <View style={styles.packageContent}>
<View <View style={[
style={[
styles.radioOuter, styles.radioOuter,
isSelected && styles.radioOuterSelected, isSelected && styles.radioOuterSelected,
]} ]}>
>
{isSelected && <View style={styles.radioInner} />} {isSelected && <View style={styles.radioInner} />}
</View> </View>
<View style={styles.packageInfo}> <View style={styles.packageInfo}>
<View style={styles.packageTitleRow}> <View style={styles.packageHeader}>
<Text style={[ <Text style={styles.packageTitleAnnual}>Annual</Text>
styles.packageTitle, <View style={styles.recommendedBadge}>
isAnnual && styles.packageTitleAnnual <Ionicons name="star" size={10} color={colors.premium.gold} />
]}> <Text style={styles.recommendedText}>BEST VALUE</Text>
{isAnnual ? "Annual" : "Monthly"}
</Text>
{isAnnual && (
<View style={styles.bestValueBadge}>
<Text style={styles.bestValueText}>BEST VALUE</Text>
</View> </View>
)}
</View> </View>
<Text style={[ <Text style={styles.packagePriceAnnual}>
styles.packagePrice,
isAnnual && styles.packagePriceAnnual
]}>
{formatPrice(pkg)} {formatPrice(pkg)}
</Text> </Text>
</View> </View>
</View> </View>
</Pressable> </Pressable>
); );
}) })}
{/* Monthly package */}
{offerings.availablePackages
.filter((p) => p.packageType === "MONTHLY")
.map((pkg) => {
const isSelected = selectedPackage?.identifier === pkg.identifier;
return (
<Pressable
key={pkg.identifier}
onPress={() => setSelectedPackage(pkg)}
style={[
styles.packageCard,
isSelected && styles.packageCardSelected,
]}
>
<View style={styles.packageContent}>
<View style={[
styles.radioOuter,
isSelected && styles.radioOuterSelected,
]}>
{isSelected && <View style={styles.radioInner} />}
</View>
<View style={styles.packageInfo}>
<Text style={styles.packageTitle}>Monthly</Text>
<Text style={styles.packagePrice}>
{formatPrice(pkg)}
</Text>
</View>
</View>
</Pressable>
);
})}
</>
) : ( ) : (
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<Text style={styles.errorText}> <Text style={styles.errorText}>
@@ -342,7 +343,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
)} )}
<TouchableOpacity <TouchableOpacity
style={styles.retryButton} style={styles.retryButton}
onPress={fetchOfferings} onPress={() => fetchOfferings(offeringId)}
> >
<Text style={styles.retryButtonText}>Retry</Text> <Text style={styles.retryButtonText}>Retry</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -379,7 +380,6 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
> >
<Ionicons name="diamond" size={18} color={colors.background} style={{ marginRight: spacing.sm }} />
<Text style={styles.purchaseButtonText}>Unlock Pro</Text> <Text style={styles.purchaseButtonText}>Unlock Pro</Text>
</LinearGradient> </LinearGradient>
)} )}
@@ -405,18 +405,19 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
<TouchableOpacity <TouchableOpacity
onPress={() => Linking.openURL("https://sauci.app/terms")} onPress={() => Linking.openURL("https://sauci.app/terms")}
> >
<Text style={styles.legalLinkText}>Terms of Use</Text> <Text style={styles.legalLinkText}>Terms</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.legalSeparator}>•</Text> <Text style={styles.legalSeparator}>•</Text>
<TouchableOpacity <TouchableOpacity
onPress={() => Linking.openURL("https://sauci.app/privacy")} onPress={() => Linking.openURL("https://sauci.app/privacy")}
> >
<Text style={styles.legalLinkText}>Privacy Policy</Text> <Text style={styles.legalLinkText}>Privacy</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
</ScrollView> </ScrollView>
{/* Guest confirmation modal */}
{showGuestConfirm && ( {showGuestConfirm && (
<View style={styles.guestConfirmOverlay}> <View style={styles.guestConfirmOverlay}>
<Pressable <Pressable
@@ -425,8 +426,8 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
/> />
<View style={styles.guestConfirmCard}> <View style={styles.guestConfirmCard}>
<View style={styles.guestConfirmHeader}> <View style={styles.guestConfirmHeader}>
<View style={styles.guestConfirmIcon}> <View style={styles.guestConfirmIconWrapper}>
<Ionicons name="shield-checkmark" size={22} color={colors.premium.gold} /> <Ionicons name="shield-checkmark" size={24} color={colors.premium.gold} />
</View> </View>
<TouchableOpacity <TouchableOpacity
onPress={() => setShowGuestConfirm(false)} onPress={() => setShowGuestConfirm(false)}
@@ -440,7 +441,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
<Text style={styles.guestConfirmTitle}>Save your account first?</Text> <Text style={styles.guestConfirmTitle}>Save your account first?</Text>
<Text style={styles.guestConfirmBody}> <Text style={styles.guestConfirmBody}>
Unsaved accounts can't be recovered if you delete the app or switch devices. Unsaved accounts can't be recovered if you delete the app or switch devices.
Saving your account protects your access to Pro. Saving protects your Pro access.
</Text> </Text>
<View style={styles.guestConfirmActions}> <View style={styles.guestConfirmActions}>
@@ -451,8 +452,15 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
handleClose("dismiss"); handleClose("dismiss");
router.push("/(app)/settings/save-account" as any); router.push("/(app)/settings/save-account" as any);
}} }}
>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
style={styles.guestConfirmPrimaryGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
> >
<Text style={styles.guestConfirmPrimaryText}>Save account</Text> <Text style={styles.guestConfirmPrimaryText}>Save account</Text>
</LinearGradient>
</Pressable> </Pressable>
<Pressable <Pressable
@@ -463,7 +471,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
await doPurchase(); await doPurchase();
}} }}
> >
<Text style={styles.guestConfirmSecondaryText}>Continue purchase</Text> <Text style={styles.guestConfirmSecondaryText}>Continue anyway</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
@@ -485,132 +493,84 @@ const styles = StyleSheet.create({
backgroundColor: colors.background, backgroundColor: colors.background,
borderTopLeftRadius: radius.xxl, borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl, borderTopRightRadius: radius.xxl,
maxHeight: "90%", maxHeight: "92%",
borderWidth: 1,
borderBottomWidth: 0,
borderColor: 'rgba(212, 175, 55, 0.2)',
overflow: 'hidden', overflow: 'hidden',
}, },
silkHighlight: { topAccent: {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
height: 150, height: 3,
borderTopLeftRadius: radius.xxl, borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl, borderTopRightRadius: radius.xxl,
}, },
premiumBorderAccent: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 2,
backgroundColor: colors.premium.gold,
opacity: 0.4,
},
scrollContent: { scrollContent: {
padding: spacing.lg, padding: spacing.lg,
paddingTop: spacing.xl, paddingTop: spacing.xxl,
paddingBottom: Platform.OS === "ios" ? 40 : spacing.lg, paddingBottom: Platform.OS === "ios" ? 44 : spacing.xl,
}, },
closeButton: { closeButton: {
position: "absolute", position: "absolute",
top: spacing.md, top: spacing.sm,
right: spacing.md, right: spacing.sm,
width: 40, width: 36,
height: 40, height: 36,
borderRadius: 20, borderRadius: 18,
backgroundColor: 'rgba(255, 255, 255, 0.08)', backgroundColor: colors.backgroundLight,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
zIndex: 10, zIndex: 10,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
}, },
// Header
header: { header: {
alignItems: "center", alignItems: "center",
marginBottom: spacing.xl, marginBottom: spacing.xl,
}, },
label: { logoContainer: {
...typography.caption1, width: 88,
fontWeight: '600', height: 88,
letterSpacing: 3,
color: colors.premium.gold,
textAlign: 'center',
marginBottom: spacing.md,
},
iconContainer: {
width: 72,
height: 72,
borderRadius: 36,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
overflow: 'hidden', marginBottom: spacing.lg,
marginBottom: spacing.md, },
shadowColor: colors.premium.gold, logo: {
shadowOffset: { width: 0, height: 0 }, width: 88,
shadowOpacity: 0.6, height: 88,
shadowRadius: 20,
elevation: 12,
}, },
title: { title: {
...typography.title1, ...typography.largeTitle,
color: colors.text, color: colors.text,
marginBottom: spacing.xs, marginBottom: spacing.xs,
textShadowColor: 'rgba(212, 175, 55, 0.3)',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 10,
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.md,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(212, 175, 55, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.premium.gold,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.8,
}, },
subtitle: { subtitle: {
...typography.body, ...typography.body,
color: colors.textSecondary, color: colors.textSecondary,
textAlign: "center", textAlign: "center",
lineHeight: 24,
}, },
// Features
features: { features: {
marginBottom: spacing.xl, marginBottom: spacing.xl,
backgroundColor: 'rgba(212, 175, 55, 0.05)', gap: spacing.sm,
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.1)',
}, },
featureRow: { featureRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
backgroundColor: colors.backgroundLight,
borderRadius: radius.md,
}, },
featureIcon: { featureIcon: {
width: 40, width: 44,
height: 40, height: 44,
borderRadius: 20, borderRadius: 22,
backgroundColor: 'rgba(212, 175, 55, 0.1)', backgroundColor: colors.premium.goldLight,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
marginRight: spacing.md, marginRight: spacing.md,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
}, },
featureText: { featureText: {
flex: 1, flex: 1,
@@ -622,7 +582,10 @@ const styles = StyleSheet.create({
featureDescription: { featureDescription: {
...typography.subhead, ...typography.subhead,
color: colors.textSecondary, color: colors.textSecondary,
marginTop: 2,
}, },
// Packages
packages: { packages: {
marginBottom: spacing.lg, marginBottom: spacing.lg,
gap: spacing.md, gap: spacing.md,
@@ -638,27 +601,28 @@ const styles = StyleSheet.create({
}, },
packageCard: { packageCard: {
position: "relative", position: "relative",
backgroundColor: 'rgba(22, 33, 62, 0.4)', backgroundColor: colors.backgroundLight,
borderRadius: radius.lg, borderRadius: radius.lg,
borderWidth: 1, borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.08)', borderColor: colors.border,
padding: spacing.md, padding: spacing.md,
overflow: "hidden", overflow: "hidden",
}, },
packageCardSelected: { packageCardSelected: {
borderColor: colors.premium.gold, borderColor: colors.premium.gold,
borderWidth: 2,
}, },
packageCardAnnual: { packageCardAnnual: {
borderColor: 'rgba(212, 175, 55, 0.3)', borderColor: colors.premium.goldLight,
},
annualBackground: {
...StyleSheet.absoluteFillObject,
backgroundColor: colors.premium.goldLight,
opacity: 0.3,
}, },
savingsBadge: { savingsBadge: {
position: "absolute", position: "absolute",
top: -1, top: 0,
right: spacing.md, right: spacing.md,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
paddingVertical: 4, paddingVertical: 4,
borderBottomLeftRadius: radius.sm, borderBottomLeftRadius: radius.sm,
@@ -680,7 +644,7 @@ const styles = StyleSheet.create({
height: 24, height: 24,
borderRadius: 12, borderRadius: 12,
borderWidth: 2, borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.3)', borderColor: colors.border,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
marginRight: spacing.md, marginRight: spacing.md,
@@ -697,7 +661,7 @@ const styles = StyleSheet.create({
packageInfo: { packageInfo: {
flex: 1, flex: 1,
}, },
packageTitleRow: { packageHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: spacing.sm, gap: spacing.sm,
@@ -707,19 +671,23 @@ const styles = StyleSheet.create({
color: colors.text, color: colors.text,
}, },
packageTitleAnnual: { packageTitleAnnual: {
...typography.headline,
color: colors.premium.champagne, color: colors.premium.champagne,
}, },
bestValueBadge: { recommendedBadge: {
backgroundColor: 'rgba(212, 175, 55, 0.2)', flexDirection: 'row',
paddingHorizontal: spacing.xs, alignItems: 'center',
gap: 4,
backgroundColor: colors.premium.goldLight,
paddingHorizontal: spacing.sm,
paddingVertical: 2, paddingVertical: 2,
borderRadius: radius.xs, borderRadius: radius.xs,
}, },
bestValueText: { recommendedText: {
...typography.caption2, ...typography.caption2,
color: colors.premium.gold, color: colors.premium.gold,
fontWeight: '700', fontWeight: '700',
letterSpacing: 0.5, letterSpacing: 0.3,
}, },
packagePrice: { packagePrice: {
...typography.subhead, ...typography.subhead,
@@ -727,9 +695,12 @@ const styles = StyleSheet.create({
marginTop: 2, marginTop: 2,
}, },
packagePriceAnnual: { packagePriceAnnual: {
...typography.subhead,
color: colors.premium.champagne, color: colors.premium.champagne,
opacity: 0.8, marginTop: 2,
}, },
// Error states
errorContainer: { errorContainer: {
alignItems: "center", alignItems: "center",
padding: spacing.lg, padding: spacing.lg,
@@ -741,12 +712,10 @@ const styles = StyleSheet.create({
marginBottom: spacing.md, marginBottom: spacing.md,
}, },
retryButton: { retryButton: {
backgroundColor: 'rgba(212, 175, 55, 0.1)', backgroundColor: colors.premium.goldLight,
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
borderRadius: radius.md, borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
}, },
retryButtonText: { retryButtonText: {
...typography.subhead, ...typography.subhead,
@@ -764,17 +733,17 @@ const styles = StyleSheet.create({
color: colors.error, color: colors.error,
textAlign: "center", textAlign: "center",
}, },
// Actions
actions: { actions: {
marginBottom: spacing.lg, marginBottom: spacing.lg,
}, },
purchaseButton: { purchaseButton: {
borderRadius: radius.lg, borderRadius: radius.lg,
overflow: "hidden", overflow: "hidden",
...shadows.lg,
shadowColor: colors.premium.gold, shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.4,
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
}, },
purchaseButtonDisabled: { purchaseButtonDisabled: {
opacity: 0.5, opacity: 0.5,
@@ -786,7 +755,6 @@ const styles = StyleSheet.create({
backgroundColor: colors.premium.gold, backgroundColor: colors.premium.gold,
}, },
purchaseButtonGradient: { purchaseButtonGradient: {
flexDirection: 'row',
paddingVertical: spacing.md, paddingVertical: spacing.md,
alignItems: "center", alignItems: "center",
justifyContent: 'center', justifyContent: 'center',
@@ -795,6 +763,7 @@ const styles = StyleSheet.create({
...typography.headline, ...typography.headline,
color: colors.background, color: colors.background,
fontWeight: '700', fontWeight: '700',
fontSize: 18,
}, },
restoreButton: { restoreButton: {
alignItems: "center", alignItems: "center",
@@ -803,11 +772,12 @@ const styles = StyleSheet.create({
}, },
restoreText: { restoreText: {
...typography.subhead, ...typography.subhead,
color: colors.premium.gold, color: colors.textSecondary,
opacity: 0.8,
}, },
// Legal
legal: { legal: {
paddingHorizontal: spacing.md, paddingHorizontal: spacing.sm,
}, },
legalText: { legalText: {
...typography.caption1, ...typography.caption1,
@@ -820,11 +790,11 @@ const styles = StyleSheet.create({
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
marginTop: spacing.sm, marginTop: spacing.sm,
gap: spacing.xs, gap: spacing.sm,
}, },
legalLinkText: { legalLinkText: {
...typography.caption1, ...typography.caption1,
color: colors.premium.gold, color: colors.textSecondary,
textDecorationLine: "underline", textDecorationLine: "underline",
}, },
legalSeparator: { legalSeparator: {
@@ -832,21 +802,22 @@ const styles = StyleSheet.create({
color: colors.textTertiary, color: colors.textTertiary,
}, },
// Guest confirm modal
guestConfirmOverlay: { guestConfirmOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.7)", backgroundColor: "rgba(0, 0, 0, 0.8)",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
padding: spacing.lg, padding: spacing.lg,
}, },
guestConfirmCard: { guestConfirmCard: {
width: "100%", width: "100%",
maxWidth: 420, maxWidth: 400,
backgroundColor: "rgba(22, 33, 62, 0.98)", backgroundColor: colors.backgroundLight,
borderRadius: radius.xl, borderRadius: radius.xl,
padding: spacing.lg, padding: spacing.lg,
borderWidth: 1, borderWidth: 1,
borderColor: "rgba(212, 175, 55, 0.2)", borderColor: colors.border,
}, },
guestConfirmHeader: { guestConfirmHeader: {
flexDirection: "row", flexDirection: "row",
@@ -854,23 +825,21 @@ const styles = StyleSheet.create({
justifyContent: "space-between", justifyContent: "space-between",
marginBottom: spacing.md, marginBottom: spacing.md,
}, },
guestConfirmIcon: { guestConfirmIconWrapper: {
width: 40, width: 48,
height: 40, height: 48,
borderRadius: 20, borderRadius: 24,
backgroundColor: "rgba(212, 175, 55, 0.12)", backgroundColor: colors.premium.goldLight,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}, },
guestConfirmClose: { guestConfirmClose: {
width: 40, width: 36,
height: 40, height: 36,
borderRadius: 20, borderRadius: 18,
backgroundColor: "rgba(255, 255, 255, 0.08)", backgroundColor: colors.background,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
}, },
guestConfirmTitle: { guestConfirmTitle: {
...typography.title3, ...typography.title3,
@@ -878,19 +847,20 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm, marginBottom: spacing.sm,
}, },
guestConfirmBody: { guestConfirmBody: {
...typography.subhead, ...typography.body,
color: colors.textSecondary, color: colors.textSecondary,
lineHeight: 20, lineHeight: 22,
marginBottom: spacing.lg, marginBottom: spacing.lg,
textAlign: "left",
}, },
guestConfirmActions: { guestConfirmActions: {
gap: spacing.sm, gap: spacing.sm,
}, },
guestConfirmPrimary: { guestConfirmPrimary: {
backgroundColor: colors.premium.gold,
paddingVertical: spacing.md,
borderRadius: radius.lg, borderRadius: radius.lg,
overflow: 'hidden',
},
guestConfirmPrimaryGradient: {
paddingVertical: spacing.md,
alignItems: "center", alignItems: "center",
}, },
guestConfirmPrimaryText: { guestConfirmPrimaryText: {
@@ -899,16 +869,16 @@ const styles = StyleSheet.create({
fontWeight: "700", fontWeight: "700",
}, },
guestConfirmSecondary: { guestConfirmSecondary: {
backgroundColor: "rgba(255, 255, 255, 0.06)", backgroundColor: colors.background,
paddingVertical: spacing.md, paddingVertical: spacing.md,
borderRadius: radius.lg, borderRadius: radius.lg,
alignItems: "center", alignItems: "center",
borderWidth: 1, borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)", borderColor: colors.border,
}, },
guestConfirmSecondaryText: { guestConfirmSecondaryText: {
...typography.subhead, ...typography.subhead,
color: colors.text, color: colors.textSecondary,
fontWeight: "600", fontWeight: "600",
}, },
}); });
@@ -0,0 +1,452 @@
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, TouchableOpacity } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
withTiming,
interpolate,
Easing,
FadeInDown,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, typography, spacing, animations } from "../../theme";
import { QuestionFeedbackModal } from "../feedback";
import { SharePreviewModal } from "../share";
const ACTION_BUTTON_SIZE = 72;
const ACTION_BUTTON_OVERHANG_PERCENT = 0.5; // 50% of button hangs below the card
const actionButtonOffset = ACTION_BUTTON_SIZE * ACTION_BUTTON_OVERHANG_PERCENT;
const actionButtonBackground = colors.muted;
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
export interface QuestionCardProps {
question: {
id: string;
text: string;
intensity: number;
partner_text?: string | null;
is_two_part?: boolean;
partner_answered?: boolean;
question_type?: string;
};
packInfo?: { name: string; icon: string; color?: string } | null;
onAnswer: (answer: 'yes' | 'no' | 'skip') => void;
onReport?: () => void;
}
export default function QuestionCard({ question, packInfo, onAnswer, onReport }: QuestionCardProps) {
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const idleBreathing = useSharedValue(0);
const handleSharePress = async () => {
await triggerHaptic('light');
setShowShareModal(true);
};
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => {
const idleOffset = interpolate(idleBreathing.value, [0, 1], [0, -4]);
return {
transform: [{ translateY: idleOffset }],
};
});
const handleButtonPress = async (answer: 'yes' | 'no' | 'skip') => {
await triggerHaptic(answer === 'skip' ? 'light' : 'medium');
onAnswer(answer);
};
const handleFeedbackPress = () => {
if (onReport) {
onReport();
} else {
setShowFeedbackModal(true);
}
};
return (
<>
<Animated.View
entering={FadeInDown.duration(400).springify()}
style={styles.cardWrapper}
>
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.lg, { shadowColor: colors.primary }]}>
<View
style={[
styles.gradientContainer,
{ backgroundColor: packInfo?.color || colors.background }
]}
>
<CardContent
question={question}
packInfo={packInfo}
handleButtonPress={handleButtonPress}
onFeedbackPress={handleFeedbackPress}
onSharePress={handleSharePress}
/>
</View>
</Animated.View>
</Animated.View>
<QuestionFeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
questionId={question.id}
questionText={question.text}
/>
<SharePreviewModal
visible={showShareModal}
onClose={() => setShowShareModal(false)}
question={question}
packName={packInfo?.name}
cardColor={packInfo?.color}
/>
</>
);
}
function CardContent({
question,
packInfo,
handleButtonPress,
onFeedbackPress,
onSharePress,
}: {
question: QuestionCardProps['question'];
packInfo?: { name: string; icon: string } | null;
handleButtonPress: (answer: 'yes' | 'no' | 'skip') => void;
onFeedbackPress: () => void;
onSharePress: () => void;
}) {
return (
<View style={styles.cardInner}>
{/* Header Row - Pack badge and action buttons */}
<View style={styles.headerRow}>
{/* Pack Badge */}
{packInfo && (
<View style={styles.packBadge}>
<Ionicons name={packInfo.icon as any} size={14} color={colors.text} />
<Text style={styles.packBadgeText} numberOfLines={1}>
{packInfo.name}
</Text>
</View>
)}
{!packInfo && <View style={styles.headerSpacer} />}
{/* Action Buttons */}
<View style={styles.headerActions}>
<TouchableOpacity
style={styles.headerButton}
onPress={onSharePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="share-outline" size={20} color="rgba(255,255,255,0.8)" />
</TouchableOpacity>
<TouchableOpacity
style={styles.headerButton}
onPress={onFeedbackPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={20} color="rgba(255,255,255,0.6)" />
</TouchableOpacity>
</View>
</View>
{/* Content - Question text centered */}
<View style={styles.content}>
<Text style={[styles.text, question.is_two_part && styles.twoPartText]}>
{question.text}
</Text>
{/* Partner's version of the question - only show when user is answering first */}
{question.partner_text && !question.partner_answered && (
<View style={styles.partnerTextContainer}>
<View style={styles.partnerDivider}>
<View style={styles.partnerDividerLine} />
<View style={styles.partnerLabelContainer}>
<Ionicons name="swap-horizontal" size={12} color="rgba(255,255,255,0.8)" />
<Text style={styles.partnerLabel}>Partner sees</Text>
</View>
<View style={styles.partnerDividerLine} />
</View>
<Text style={styles.partnerText}>{question.partner_text}</Text>
</View>
)}
</View>
{/* Footer - Action Buttons pinned to bottom */}
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<ActionButton
onPress={() => handleButtonPress("no")}
icon="close"
iconColor={colors.error}
label="NO"
/>
{/* Skip button in between */}
<TouchableOpacity
style={styles.skipButtonCenter}
onPress={() => handleButtonPress("skip")}
activeOpacity={0.7}
>
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
<ActionButton
onPress={() => handleButtonPress("yes")}
icon="checkmark"
iconColor={colors.success}
label="YES"
/>
</View>
</View>
</View>
);
}
function ActionButton({
onPress,
icon,
iconColor,
label,
small = false,
}: {
onPress: () => void;
icon: string;
iconColor: string;
label: string;
small?: boolean;
}) {
const buttonScale = useSharedValue(1);
const handlePressIn = () => {
buttonScale.value = withSpring(0.92, animations.spring);
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
}));
return (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
style={styles.actionButtonWrapper}
>
<Animated.View style={[
styles.actionButton,
small && styles.actionButtonSmall,
buttonAnimatedStyle,
{ backgroundColor: actionButtonBackground }
]}>
<Ionicons
name={icon as any}
size={small ? 20 : 32}
color={iconColor}
style={styles.buttonIcon}
/>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
width: "90%",
maxWidth: 400,
flex: 1,
maxHeight: 580 - actionButtonOffset, // Account for button overhang
},
cardOuter: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
gradientContainer: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
cardInner: {
flex: 1,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
paddingHorizontal: spacing.lg,
},
// Header row styles
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
headerSpacer: {
flex: 1,
},
packBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.15)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
maxWidth: '60%',
},
packBadgeText: {
...typography.caption1,
fontWeight: '600',
color: colors.text,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.12)',
justifyContent: 'center',
alignItems: 'center',
},
// Content styles
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.sm,
},
text: {
...typography.title1,
color: colors.text,
textAlign: "center",
lineHeight: 36,
},
twoPartText: {
...typography.title2,
lineHeight: 32,
},
partnerTextContainer: {
marginTop: spacing.lg,
width: '100%',
paddingHorizontal: spacing.sm,
},
partnerDivider: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
partnerDividerLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(255,255,255,0.2)',
},
partnerLabelContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
backgroundColor: 'rgba(255,255,255,0.15)',
borderRadius: radius.full,
gap: spacing.xs,
},
partnerLabel: {
...typography.caption1,
color: 'rgba(255,255,255,0.8)',
fontWeight: '500',
letterSpacing: 0.3,
},
partnerText: {
...typography.subhead,
color: 'rgba(255,255,255,0.7)',
textAlign: 'center',
fontStyle: 'italic',
},
// Footer styles
footer: {
paddingTop: spacing.md,
transform: [{ translateY: actionButtonOffset }],
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
width: '100%',
paddingHorizontal: spacing.md,
},
actionButtonWrapper: {
alignItems: 'center',
gap: spacing.xs,
},
actionButton: {
width: 72,
height: 72,
borderRadius: 36,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
elevation: 4,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
actionButtonSmall: {
width: 52,
height: 52,
borderRadius: 26,
},
buttonIcon: {
zIndex: 1,
},
skipButtonCenter: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
marginBottom: 30, // Align above center of 72px buttons
},
skipText: {
...typography.caption1,
color: 'rgba(255,255,255,0.5)',
fontWeight: '600',
},
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,630 @@
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, TouchableOpacity, Image } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
withTiming,
interpolate,
Easing,
FadeInDown,
FadeIn,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, typography, spacing, animations } from "../../theme";
import { useMediaPicker } from "../../hooks/useMediaPicker";
import { SharePreviewModal } from "../share";
const ACTION_BUTTON_SIZE = 72;
const ACTION_BUTTON_OVERHANG_PERCENT = 0.5;
const actionButtonOffset = ACTION_BUTTON_SIZE * ACTION_BUTTON_OVERHANG_PERCENT;
const actionButtonBackground = colors.muted;
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
export interface QuestionCardPhotoProps {
question: {
id: string;
text: string;
intensity: number;
partner_text?: string | null;
partner_answered?: boolean;
};
packInfo?: { name: string; icon: string; color?: string } | null;
onAnswer: (answer: 'yes' | 'skip', responseData?: { type: 'photo'; media_path: string }) => void;
onReport?: () => void;
}
export default function QuestionCardPhoto({ question, packInfo, onAnswer, onReport }: QuestionCardPhotoProps) {
const [selectedPhotoUri, setSelectedPhotoUri] = useState<string | null>(null);
const [showShareModal, setShowShareModal] = useState(false);
const { takePhoto, pickMedia } = useMediaPicker({ imageQuality: 0.8 });
const idleBreathing = useSharedValue(0);
const handleSharePress = async () => {
await triggerHaptic('light');
setShowShareModal(true);
};
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => {
const idleOffset = interpolate(idleBreathing.value, [0, 1], [0, -4]);
return {
transform: [{ translateY: idleOffset }],
};
});
const handleTakePhoto = async () => {
await triggerHaptic('light');
const result = await takePhoto();
if (result) {
setSelectedPhotoUri(result.uri);
}
};
const handlePickFromLibrary = async () => {
await triggerHaptic('light');
const result = await pickMedia();
if (result && result.mediaType === 'image') {
setSelectedPhotoUri(result.uri);
}
};
const handleConfirm = async () => {
if (!selectedPhotoUri) return;
await triggerHaptic('medium');
onAnswer('yes', { type: 'photo', media_path: selectedPhotoUri });
};
const handleRetake = async () => {
await triggerHaptic('light');
setSelectedPhotoUri(null);
};
const handleCancel = async () => {
await triggerHaptic('light');
setSelectedPhotoUri(null);
};
const handleSkip = async () => {
await triggerHaptic('light');
onAnswer('skip');
};
return (
<>
<Animated.View
entering={FadeInDown.duration(400).springify()}
style={styles.cardWrapper}
>
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.lg, { shadowColor: colors.primary }]}>
<View
style={[
styles.gradientContainer,
{ backgroundColor: packInfo?.color || colors.background }
]}
>
{selectedPhotoUri ? (
<PhotoPreviewContent
question={question}
packInfo={packInfo}
photoUri={selectedPhotoUri}
onConfirm={handleConfirm}
onRetake={handleRetake}
onCancel={handleCancel}
onReport={onReport}
/>
) : (
<PhotoSelectContent
question={question}
packInfo={packInfo}
onTakePhoto={handleTakePhoto}
onPickFromLibrary={handlePickFromLibrary}
onSkip={handleSkip}
onReport={onReport}
onSharePress={handleSharePress}
/>
)}
</View>
</Animated.View>
</Animated.View>
<SharePreviewModal
visible={showShareModal}
onClose={() => setShowShareModal(false)}
question={question}
packName={packInfo?.name}
cardColor={packInfo?.color}
/>
</>
);
}
// State A - No photo selected
function PhotoSelectContent({
question,
packInfo,
onTakePhoto,
onPickFromLibrary,
onSkip,
onReport,
onSharePress,
}: {
question: QuestionCardPhotoProps['question'];
packInfo?: { name: string; icon: string } | null;
onTakePhoto: () => void;
onPickFromLibrary: () => void;
onSkip: () => void;
onReport?: () => void;
onSharePress: () => void;
}) {
return (
<View style={styles.cardInner}>
{/* Header Row */}
<View style={styles.headerRow}>
{packInfo && (
<View style={styles.packBadge}>
<Ionicons name={packInfo.icon as any} size={14} color={colors.text} />
<Text style={styles.packBadgeText} numberOfLines={1}>
{packInfo.name}
</Text>
</View>
)}
{!packInfo && <View style={styles.headerSpacer} />}
<View style={styles.headerActions}>
<TouchableOpacity
style={styles.headerButton}
onPress={onSharePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="share-outline" size={20} color="rgba(255,255,255,0.8)" />
</TouchableOpacity>
{onReport && (
<TouchableOpacity
style={styles.headerButton}
onPress={onReport}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={20} color="rgba(255,255,255,0.6)" />
</TouchableOpacity>
)}
</View>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.text}>
{question.text}
</Text>
</View>
{/* Footer */}
<View style={styles.footer}>
<View style={styles.photoButtonContainer}>
<PhotoActionButton
onPress={onPickFromLibrary}
icon="images"
label="Library"
colors={[colors.secondary, colors.secondaryDark]}
/>
<TouchableOpacity
style={styles.skipButtonCenter}
onPress={onSkip}
activeOpacity={0.7}
>
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
<PhotoActionButton
onPress={onTakePhoto}
icon="camera"
label="Camera"
colors={gradients.primary as [string, string]}
/>
</View>
</View>
</View>
);
}
// State B - Photo preview
function PhotoPreviewContent({
question,
packInfo,
photoUri,
onConfirm,
onRetake,
onCancel,
onReport,
}: {
question: QuestionCardPhotoProps['question'];
packInfo?: { name: string; icon: string } | null;
photoUri: string;
onConfirm: () => void;
onRetake: () => void;
onCancel: () => void;
onReport?: () => void;
}) {
return (
<View style={styles.cardInner}>
{/* Header Row */}
<View style={styles.headerRow}>
{packInfo && (
<View style={styles.packBadge}>
<Ionicons name={packInfo.icon as any} size={14} color={colors.text} />
<Text style={styles.packBadgeText} numberOfLines={1}>
{packInfo.name}
</Text>
</View>
)}
{!packInfo && <View style={styles.headerSpacer} />}
<View style={styles.headerActions}>
{onReport && (
<TouchableOpacity
style={styles.headerButton}
onPress={onReport}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={20} color="rgba(255,255,255,0.6)" />
</TouchableOpacity>
)}
</View>
</View>
{/* Content */}
<View style={styles.previewContent}>
<Text style={styles.previewText} numberOfLines={2}>
{question.text}
</Text>
{/* Photo Preview */}
<Animated.View
entering={FadeIn.duration(300)}
style={styles.photoPreviewContainer}
>
<Image
source={{ uri: photoUri }}
style={styles.photoPreview}
resizeMode="cover"
/>
<View style={styles.photoPreviewBorder} pointerEvents="none" />
</Animated.View>
</View>
{/* Footer */}
<View style={styles.previewFooter}>
<View style={styles.previewButtonRow}>
<TouchableOpacity
style={styles.secondaryButton}
onPress={onCancel}
activeOpacity={0.7}
>
<Ionicons name="close" size={20} color="rgba(255,255,255,0.7)" />
<Text style={styles.secondaryButtonText}>Cancel</Text>
</TouchableOpacity>
<ConfirmButton onPress={onConfirm} />
<TouchableOpacity
style={styles.secondaryButton}
onPress={onRetake}
activeOpacity={0.7}
>
<Ionicons name="refresh" size={20} color="rgba(255,255,255,0.7)" />
<Text style={styles.secondaryButtonText}>Retake</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
}
function PhotoActionButton({
onPress,
colors: _buttonColors,
icon,
label,
}: {
onPress: () => void;
colors: [string, string];
icon: string;
label: string;
}) {
const buttonScale = useSharedValue(1);
const handlePressIn = () => {
buttonScale.value = withSpring(0.95, animations.spring);
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
}));
return (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
style={styles.photoActionWrapper}
>
<Animated.View style={[
styles.photoActionButton,
buttonAnimatedStyle,
{ backgroundColor: actionButtonBackground }
]}>
<Ionicons
name={icon as any}
size={28}
color="white"
style={styles.buttonIcon}
/>
</Animated.View>
</TouchableOpacity>
);
}
function ConfirmButton({ onPress }: { onPress: () => void }) {
const buttonScale = useSharedValue(1);
const handlePressIn = () => {
buttonScale.value = withSpring(0.92, animations.spring);
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
}));
return (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
>
<Animated.View style={[
styles.confirmButton,
buttonAnimatedStyle,
{ backgroundColor: colors.success }
]}>
<LinearGradient
colors={gradients.success as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Ionicons
name="checkmark"
size={28}
color={colors.text}
style={styles.buttonIcon}
/>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
width: "90%",
maxWidth: 400,
flex: 1,
maxHeight: 580 - actionButtonOffset, // Account for button overhang
},
cardOuter: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
gradientContainer: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
cardInner: {
flex: 1,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
paddingHorizontal: spacing.lg,
},
// Header row styles
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
headerSpacer: {
flex: 1,
},
packBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.15)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
maxWidth: '60%',
},
packBadgeText: {
...typography.caption1,
fontWeight: '600',
color: colors.text,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.12)',
justifyContent: 'center',
alignItems: 'center',
},
// Content styles
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.sm,
},
text: {
...typography.title1,
color: colors.text,
textAlign: "center",
lineHeight: 36,
},
// Footer styles
footer: {
paddingTop: spacing.md,
transform: [{ translateY: actionButtonOffset }],
},
previewFooter: {
paddingTop: spacing.md,
paddingBottom: spacing.sm,
},
photoButtonContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
width: '100%',
paddingHorizontal: spacing.md,
},
photoActionWrapper: {
alignItems: 'center',
gap: spacing.xs,
},
photoActionButton: {
width: 72,
height: 72,
borderRadius: 36,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
elevation: 4,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
buttonIcon: {
zIndex: 1,
},
photoActionButtonLabel: {
...typography.caption1,
color: colors.text,
fontWeight: '600',
},
skipButtonCenter: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
marginBottom: 30, // Align above center of 72px buttons
},
skipText: {
...typography.caption1,
color: 'rgba(255,255,255,0.5)',
fontWeight: '600',
},
// Preview state styles
previewContent: {
flex: 1,
paddingTop: spacing.sm,
},
previewText: {
...typography.headline,
color: colors.text,
textAlign: "center",
marginBottom: spacing.md,
},
photoPreviewContainer: {
flex: 1,
marginBottom: spacing.sm,
borderRadius: radius.lg,
overflow: 'hidden',
backgroundColor: 'rgba(0,0,0,0.2)',
},
photoPreview: {
width: '100%',
height: '100%',
borderRadius: radius.lg,
},
photoPreviewBorder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: 'rgba(255,255,255,0.2)',
},
previewButtonRow: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: spacing.md,
},
secondaryButton: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
gap: spacing.xs,
},
secondaryButtonText: {
...typography.caption1,
color: 'rgba(255,255,255,0.7)',
},
confirmButton: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
},
});
@@ -0,0 +1,496 @@
import { useState, useEffect } from "react";
import {
View,
Text,
StyleSheet,
Platform,
TouchableOpacity,
TextInput,
Keyboard,
TouchableWithoutFeedback,
} from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
withTiming,
interpolate,
Easing,
FadeInDown,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, typography, spacing, animations } from "../../theme";
import { QuestionFeedbackModal } from "../feedback";
import { SharePreviewModal } from "../share";
const ACTION_BUTTON_SIZE = 72;
const ACTION_BUTTON_OVERHANG_PERCENT = 0.5;
const actionButtonOffset = ACTION_BUTTON_SIZE * ACTION_BUTTON_OVERHANG_PERCENT;
const actionButtonBackground = colors.muted;
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
const MAX_TEXT_LENGTH = 500;
export interface QuestionCardTextProps {
question: {
id: string;
text: string;
intensity: number;
partner_text?: string | null;
partner_answered?: boolean;
};
packInfo?: { name: string; icon: string; color?: string } | null;
onAnswer: (answer: 'yes' | 'no' | 'skip', responseData?: { type: 'text_answer'; text: string }) => void;
onReport?: () => void;
}
export default function QuestionCardText({ question, packInfo, onAnswer, onReport }: QuestionCardTextProps) {
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [textValue, setTextValue] = useState('');
const [isFocused, setIsFocused] = useState(false);
const idleBreathing = useSharedValue(0);
const handleSharePress = async () => {
await triggerHaptic('light');
setShowShareModal(true);
};
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => {
const idleOffset = interpolate(idleBreathing.value, [0, 1], [0, -4]);
return {
transform: [{ translateY: idleOffset }],
};
});
const handleButtonPress = async (answer: 'yes' | 'no' | 'skip') => {
Keyboard.dismiss();
await triggerHaptic(answer === 'skip' ? 'light' : 'medium');
if (answer === 'yes' && textValue.trim().length > 0) {
onAnswer(answer, { type: 'text_answer', text: textValue.trim() });
} else {
onAnswer(answer);
}
};
const handleFeedbackPress = () => {
if (onReport) {
onReport();
} else {
setShowFeedbackModal(true);
}
};
const isYesDisabled = textValue.trim().length === 0;
return (
<>
<Animated.View
entering={FadeInDown.duration(400).springify()}
style={styles.cardWrapper}
>
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.lg, { shadowColor: colors.primary }]}>
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<View
style={[
styles.gradientContainer,
{ backgroundColor: packInfo?.color || colors.background }
]}
>
<CardContent
question={question}
packInfo={packInfo}
textValue={textValue}
setTextValue={setTextValue}
isFocused={isFocused}
setIsFocused={setIsFocused}
isYesDisabled={isYesDisabled}
handleButtonPress={handleButtonPress}
onFeedbackPress={handleFeedbackPress}
onSharePress={handleSharePress}
/>
</View>
</TouchableWithoutFeedback>
</Animated.View>
</Animated.View>
<QuestionFeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
questionId={question.id}
questionText={question.text}
/>
<SharePreviewModal
visible={showShareModal}
onClose={() => setShowShareModal(false)}
question={question}
packName={packInfo?.name}
cardColor={packInfo?.color}
/>
</>
);
}
function CardContent({
question,
packInfo,
textValue,
setTextValue,
isFocused,
setIsFocused,
isYesDisabled,
handleButtonPress,
onFeedbackPress,
onSharePress,
}: {
question: QuestionCardTextProps['question'];
packInfo?: { name: string; icon: string } | null;
textValue: string;
setTextValue: (value: string) => void;
isFocused: boolean;
setIsFocused: (focused: boolean) => void;
isYesDisabled: boolean;
handleButtonPress: (answer: 'yes' | 'no' | 'skip') => void;
onFeedbackPress: () => void;
onSharePress: () => void;
}) {
return (
<View style={styles.cardInner}>
{/* Header Row - Pack badge and action buttons */}
<View style={styles.headerRow}>
{/* Pack Badge */}
{packInfo && (
<View style={styles.packBadge}>
<Ionicons name={packInfo.icon as any} size={14} color={colors.text} />
<Text style={styles.packBadgeText} numberOfLines={1}>
{packInfo.name}
</Text>
</View>
)}
{!packInfo && <View style={styles.headerSpacer} />}
{/* Action Buttons */}
<View style={styles.headerActions}>
<TouchableOpacity
style={styles.headerButton}
onPress={onSharePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="share-outline" size={20} color="rgba(255,255,255,0.8)" />
</TouchableOpacity>
<TouchableOpacity
style={styles.headerButton}
onPress={onFeedbackPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={20} color="rgba(255,255,255,0.6)" />
</TouchableOpacity>
</View>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.text}>
{question.text}
</Text>
{/* Text Input Area - Glass style */}
<View style={[
styles.inputContainer,
isFocused && styles.inputContainerFocused
]}>
<TextInput
style={styles.textInput}
value={textValue}
onChangeText={(text) => setTextValue(text.slice(0, MAX_TEXT_LENGTH))}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder="Type your answer..."
placeholderTextColor="rgba(255,255,255,0.4)"
multiline
textAlignVertical="top"
maxLength={MAX_TEXT_LENGTH}
/>
<View style={styles.charCountContainer}>
<Text style={[
styles.charCount,
textValue.length >= MAX_TEXT_LENGTH && styles.charCountMax
]}>
{textValue.length}/{MAX_TEXT_LENGTH}
</Text>
</View>
</View>
</View>
{/* Footer - Action Buttons */}
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<ActionButton
onPress={() => handleButtonPress("no")}
icon="close"
iconColor={colors.error}
label="NO"
/>
{/* Skip button in between */}
<TouchableOpacity
style={styles.skipButtonCenter}
onPress={() => handleButtonPress("skip")}
activeOpacity={0.7}
>
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
<ActionButton
onPress={() => handleButtonPress("yes")}
icon="checkmark"
iconColor={colors.success}
label="YES"
disabled={isYesDisabled}
/>
</View>
</View>
</View>
);
}
function ActionButton({
onPress,
icon,
iconColor,
label,
disabled = false,
}: {
onPress: () => void;
icon: string;
iconColor: string;
label: string;
disabled?: boolean;
}) {
const buttonScale = useSharedValue(1);
const handlePressIn = () => {
if (disabled) return;
buttonScale.value = withSpring(0.92, animations.spring);
};
const handlePressOut = () => {
if (disabled) return;
buttonScale.value = withSpring(1, animations.spring);
};
const handlePress = () => {
if (disabled) return;
onPress();
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
}));
return (
<TouchableOpacity
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={disabled ? 1 : 1}
disabled={disabled}
style={styles.actionButtonWrapper}
>
<Animated.View style={[
styles.actionButton,
buttonAnimatedStyle,
{ backgroundColor: actionButtonBackground }
]}>
<Ionicons
name={icon as any}
size={32}
color={iconColor}
style={[styles.buttonIcon, disabled && { opacity: 0.4 }]}
/>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
width: "90%",
maxWidth: 400,
flex: 1,
maxHeight: 580 - actionButtonOffset, // Account for button overhang
},
cardOuter: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
gradientContainer: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
cardInner: {
flex: 1,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
paddingHorizontal: spacing.lg,
},
// Header row styles
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
headerSpacer: {
flex: 1,
},
packBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.15)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
maxWidth: '60%',
},
packBadgeText: {
...typography.caption1,
fontWeight: '600',
color: colors.text,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.12)',
justifyContent: 'center',
alignItems: 'center',
},
// Content styles
content: {
flex: 1,
justifyContent: "flex-start",
alignItems: "center",
paddingTop: spacing.sm,
},
text: {
...typography.title2,
color: colors.text,
textAlign: "center",
lineHeight: 30,
marginBottom: spacing.lg,
},
// Glass-style input
inputContainer: {
width: '100%',
flex: 1,
minHeight: 100,
maxHeight: 180,
backgroundColor: 'rgba(255,255,255,0.12)',
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.15)',
padding: spacing.md,
},
inputContainerFocused: {
borderColor: 'rgba(255,255,255,0.4)',
backgroundColor: 'rgba(255,255,255,0.18)',
},
textInput: {
flex: 1,
...typography.body,
color: colors.text,
textAlignVertical: 'top',
},
charCountContainer: {
alignItems: 'flex-end',
marginTop: spacing.xs,
},
charCount: {
...typography.caption2,
color: 'rgba(255,255,255,0.5)',
},
charCountMax: {
color: colors.error,
},
// Footer styles
footer: {
paddingTop: spacing.md,
transform: [{ translateY: actionButtonOffset }],
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
width: '100%',
paddingHorizontal: spacing.md,
},
actionButtonWrapper: {
alignItems: 'center',
gap: spacing.xs,
},
actionButton: {
width: 72,
height: 72,
borderRadius: 36,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
elevation: 4,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
buttonIcon: {
zIndex: 1,
},
skipButtonCenter: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
marginBottom: 30, // Align above center of 72px buttons
},
skipText: {
...typography.caption1,
color: 'rgba(255,255,255,0.5)',
fontWeight: '600',
},
});
@@ -0,0 +1,478 @@
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, TouchableOpacity } from "react-native";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
withTiming,
interpolate,
Easing,
FadeInDown,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, typography, spacing, animations } from "../../theme";
import { QuestionFeedbackModal } from "../feedback";
import { SharePreviewModal } from "../share";
const AVATAR_SIZE = 72;
const AVATAR_OVERHANG_PERCENT = 0.5; // 50% of avatar hangs below the card
const actionButtonOffset = AVATAR_SIZE * AVATAR_OVERHANG_PERCENT;
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
export interface QuestionCardWhoLikelyProps {
question: {
id: string;
text: string;
intensity: number;
};
packInfo?: { name: string; icon: string; color?: string } | null;
user: {
id: string;
name?: string;
avatar_url?: string | null;
};
partner: {
id: string;
name?: string;
avatar_url?: string | null;
};
onAnswer: (responseData: { type: 'who_likely'; chosen_user_id: string }) => void;
onSkip?: () => void;
onReport?: () => void;
}
// Get initials from name
function getInitials(name?: string): string {
if (!name) return "?";
const parts = name.trim().split(/\s+/);
if (parts.length === 1) {
return parts[0].charAt(0).toUpperCase();
}
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
}
export default function QuestionCardWhoLikely({ question, packInfo, user, partner, onAnswer, onSkip, onReport }: QuestionCardWhoLikelyProps) {
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const idleBreathing = useSharedValue(0);
const handleSharePress = async () => {
await triggerHaptic('light');
setShowShareModal(true);
};
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => {
const idleOffset = interpolate(idleBreathing.value, [0, 1], [0, -4]);
return {
transform: [{ translateY: idleOffset }],
};
});
const handleAvatarPress = async (userId: string) => {
await triggerHaptic('medium');
setSelectedId(userId);
// Brief delay to show selection before triggering answer
setTimeout(() => {
onAnswer({ type: 'who_likely', chosen_user_id: userId });
}, 200);
};
const handleSkipPress = async () => {
await triggerHaptic('light');
onSkip?.();
};
const handleFeedbackPress = () => {
if (onReport) {
onReport();
} else {
setShowFeedbackModal(true);
}
};
return (
<>
<Animated.View
entering={FadeInDown.duration(400).springify()}
style={styles.cardWrapper}
>
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.lg, { shadowColor: colors.primary }]}>
<View
style={[styles.gradientContainer, { backgroundColor: packInfo?.color || colors.background }]}
>
<CardContent
question={question}
packInfo={packInfo}
user={user}
partner={partner}
selectedId={selectedId}
onAvatarPress={handleAvatarPress}
onSkipPress={handleSkipPress}
onFeedbackPress={handleFeedbackPress}
onSharePress={handleSharePress}
/>
</View>
</Animated.View>
</Animated.View>
<QuestionFeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
questionId={question.id}
questionText={question.text}
/>
<SharePreviewModal
visible={showShareModal}
onClose={() => setShowShareModal(false)}
question={question}
packName={packInfo?.name}
cardColor={packInfo?.color}
/>
</>
);
}
function CardContent({
question,
packInfo,
user,
partner,
selectedId,
onAvatarPress,
onSkipPress,
onFeedbackPress,
onSharePress,
}: {
question: QuestionCardWhoLikelyProps['question'];
packInfo?: { name: string; icon: string; color?: string } | null;
user: QuestionCardWhoLikelyProps['user'];
partner: QuestionCardWhoLikelyProps['partner'];
selectedId: string | null;
onAvatarPress: (userId: string) => void;
onSkipPress: () => void;
onFeedbackPress: () => void;
onSharePress: () => void;
}) {
return (
<View style={styles.cardInner}>
{/* Header Row */}
<View style={styles.headerRow}>
{packInfo && (
<View style={styles.packBadge}>
<Ionicons name={packInfo.icon as any} size={14} color={colors.text} />
<Text style={styles.packBadgeText} numberOfLines={1}>
{packInfo.name}
</Text>
</View>
)}
{!packInfo && <View style={styles.headerSpacer} />}
<View style={styles.headerActions}>
<TouchableOpacity
style={styles.headerButton}
onPress={onSharePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="share-outline" size={20} color="rgba(255,255,255,0.8)" />
</TouchableOpacity>
<TouchableOpacity
style={styles.headerButton}
onPress={onFeedbackPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={20} color="rgba(255,255,255,0.6)" />
</TouchableOpacity>
</View>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.text}>
{question.text}
</Text>
</View>
{/* Footer - Avatar Buttons */}
<View style={styles.footer}>
<View style={styles.avatarContainer}>
<AvatarButton
userId={user.id}
name={user.name}
avatarUrl={user.avatar_url}
isSelected={selectedId === user.id}
onPress={onAvatarPress}
/>
<TouchableOpacity
style={styles.skipButtonCenter}
onPress={onSkipPress}
activeOpacity={0.7}
>
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
<AvatarButton
userId={partner.id}
name={partner.name}
avatarUrl={partner.avatar_url}
isSelected={selectedId === partner.id}
onPress={onAvatarPress}
/>
</View>
</View>
</View>
);
}
function AvatarButton({
userId,
name,
avatarUrl,
isSelected,
onPress,
}: {
userId: string;
name?: string;
avatarUrl?: string | null;
isSelected: boolean;
onPress: (userId: string) => void;
}) {
const buttonScale = useSharedValue(1);
const buttonGlow = useSharedValue(0);
// Animate selection
useEffect(() => {
if (isSelected) {
buttonScale.value = withSequence(
withSpring(1.1, animations.springBouncy),
withSpring(1.05, animations.spring)
);
buttonGlow.value = withTiming(1, { duration: 200 });
} else {
buttonScale.value = withSpring(1, animations.spring);
buttonGlow.value = withTiming(0, { duration: 200 });
}
}, [isSelected]);
const handlePressIn = () => {
if (!isSelected) {
buttonScale.value = withSpring(0.95, animations.spring);
buttonGlow.value = withTiming(0.5, { duration: 100 });
}
};
const handlePressOut = () => {
if (!isSelected) {
buttonScale.value = withSpring(1, animations.spring);
buttonGlow.value = withTiming(0, { duration: 150 });
}
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
shadowOpacity: interpolate(buttonGlow.value, [0, 1], [0.2, 0.6]),
shadowRadius: interpolate(buttonGlow.value, [0, 1], [8, 20]),
}));
const borderAnimatedStyle = useAnimatedStyle(() => ({
borderWidth: interpolate(buttonGlow.value, [0, 1], [3, 4]),
borderColor: colors.text, // Solid white border
}));
return (
<TouchableOpacity
onPress={() => onPress(userId)}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
style={styles.avatarButtonWrapper}
>
<Animated.View style={[
styles.avatarButton,
buttonAnimatedStyle,
{ shadowColor: colors.text }
]}>
<Animated.View style={[styles.avatarBorder, borderAnimatedStyle]}>
{avatarUrl ? (
<Image
source={{ uri: avatarUrl }}
style={styles.avatarImage}
contentFit="cover"
cachePolicy="memory-disk"
transition={200}
/>
) : (
<View style={styles.avatarFallback}>
<Text style={styles.avatarInitials}>
{getInitials(name)}
</Text>
</View>
)}
</Animated.View>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
width: "90%",
maxWidth: 400,
flex: 1,
maxHeight: 580 - actionButtonOffset, // Account for avatar overhang
},
cardOuter: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
gradientContainer: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
cardInner: {
flex: 1,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
paddingHorizontal: spacing.lg,
},
// Header row styles
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
headerSpacer: {
flex: 1,
},
packBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.15)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
maxWidth: '60%',
},
packBadgeText: {
...typography.caption1,
fontWeight: '600',
color: colors.text,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.12)',
justifyContent: 'center',
alignItems: 'center',
},
// Content styles
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.sm,
},
text: {
...typography.title1,
color: colors.text,
textAlign: "center",
lineHeight: 36,
},
// Footer styles
footer: {
paddingTop: spacing.md,
transform: [{ translateY: actionButtonOffset }],
},
avatarContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'flex-end',
gap: spacing.lg,
},
avatarButtonWrapper: {
alignItems: 'center',
},
avatarButton: {
width: AVATAR_SIZE,
height: AVATAR_SIZE,
borderRadius: AVATAR_SIZE / 2,
justifyContent: 'center',
alignItems: 'center',
shadowOffset: { width: 0, height: 4 },
},
avatarBorder: {
width: AVATAR_SIZE,
height: AVATAR_SIZE,
borderRadius: AVATAR_SIZE / 2,
borderWidth: 3,
borderColor: colors.text,
},
avatarImage: {
width: AVATAR_SIZE - 6, // Account for border width (3px each side)
height: AVATAR_SIZE - 6,
borderRadius: (AVATAR_SIZE - 6) / 2,
},
avatarFallback: {
width: AVATAR_SIZE - 6,
height: AVATAR_SIZE - 6,
borderRadius: (AVATAR_SIZE - 6) / 2,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.primary,
},
avatarInitials: {
...typography.title1,
color: colors.text,
fontWeight: 'bold',
},
skipButtonCenter: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
marginBottom: 30, // Align above center of 72px avatars
},
skipText: {
...typography.caption1,
color: 'rgba(255,255,255,0.5)',
fontWeight: '600',
},
});
@@ -0,0 +1,10 @@
export { default as QuestionCard } from './QuestionCard';
export type { QuestionCardProps } from './QuestionCard';
export { default as QuestionCardText } from './QuestionCardText';
export type { QuestionCardTextProps } from './QuestionCardText';
export { default as QuestionCardWhoLikely } from './QuestionCardWhoLikely';
export type { QuestionCardWhoLikelyProps } from './QuestionCardWhoLikely';
export { default as QuestionCardPhoto } from './QuestionCardPhoto';
export type { QuestionCardPhotoProps } from './QuestionCardPhoto';
export { default as QuestionCardAudio } from './QuestionCardAudio';
export type { QuestionCardAudioProps } from './QuestionCardAudio';
@@ -1,7 +1,8 @@
import { useState, useEffect } from "react"; import { useState, useEffect, type ReactNode } from "react";
import { import {
View, View,
Text, Text,
TextInput,
StyleSheet, StyleSheet,
Modal, Modal,
TouchableOpacity, TouchableOpacity,
@@ -10,20 +11,23 @@ import {
Platform, Platform,
} from "react-native"; } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated"; import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
import type { ResponseWithQuestion, UpdateResponseResult } from "../../store"; import type { ResponseWithQuestion } from "../../store";
import { useResponsesStore } from "../../store"; import { useAuthStore, useResponsesStore } from "../../store";
import type { AnswerType } from "@/types"; import { useMediaPicker } from "../../hooks/useMediaPicker";
import { useResponseMediaUpload } from "../../hooks/useResponseMediaUpload";
import { useAudioRecorder } from "../../hooks/useAudioRecorder";
import type { AnswerType, ResponseData } from "@/types";
import { colors, spacing, typography, radius } from "../../theme"; import { colors, spacing, typography, radius } from "../../theme";
import { DeleteMatchConfirmModal } from "./DeleteMatchConfirmModal"; import { DeleteMatchConfirmModal } from "./DeleteMatchConfirmModal";
// Premium color palette // Premium color palette
const ACCENT = colors.premium.gold; const ACCENT = colors.premium.gold;
const ACCENT_RGBA = "rgba(212, 175, 55, "; const ACCENT_RGBA = "rgba(212, 175, 55, ";
const MAX_TEXT_LENGTH = 500;
// Answer button config // Answer button config (includes 'maybe' for displaying existing responses)
const ANSWER_CONFIG = { const ANSWER_CONFIG = {
yes: { yes: {
color: colors.success, color: colors.success,
@@ -31,20 +35,23 @@ const ANSWER_CONFIG = {
icon: "checkmark-circle" as const, icon: "checkmark-circle" as const,
label: "Yes", label: "Yes",
}, },
maybe: {
color: colors.warning,
rgba: "rgba(243, 156, 18, ",
icon: "help-circle" as const,
label: "Maybe",
},
no: { no: {
color: colors.error, color: colors.error,
rgba: "rgba(231, 76, 60, ", rgba: "rgba(231, 76, 60, ",
icon: "close-circle" as const, icon: "close-circle" as const,
label: "No", label: "No",
}, },
maybe: {
color: colors.warning,
rgba: "rgba(243, 156, 18, ",
icon: "help-circle" as const,
label: "Maybe",
},
}; };
// Only these answer types can be selected by users
type SelectableAnswerType = "yes" | "no";
interface EditResponseSheetProps { interface EditResponseSheetProps {
visible: boolean; visible: boolean;
response: ResponseWithQuestion | null; response: ResponseWithQuestion | null;
@@ -54,12 +61,32 @@ interface EditResponseSheetProps {
export function EditResponseSheet({ visible, response, onClose, onSuccess }: EditResponseSheetProps) { export function EditResponseSheet({ visible, response, onClose, onSuccess }: EditResponseSheetProps) {
const { updateResponse } = useResponsesStore(); const { updateResponse } = useResponsesStore();
const { user, partner } = useAuthStore();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [pendingAnswer, setPendingAnswer] = useState<AnswerType | null>(null); const [pendingAnswer, setPendingAnswer] = useState<AnswerType | null>(null);
const [confirmModalData, setConfirmModalData] = useState<{ const [confirmModalData, setConfirmModalData] = useState<{
matchId: string; matchId: string;
messageCount: number; messageCount: number;
} | null>(null); } | null>(null);
const [textValue, setTextValue] = useState("");
const [selectedWhoLikely, setSelectedWhoLikely] = useState<string | null>(null);
const questionId = response?.question_id ?? "";
const maxDurationSeconds = response?.question.config?.max_duration_seconds ?? 60;
const { takePhoto, pickMedia } = useMediaPicker({ imageQuality: 0.8 });
const { uploading, uploadPhoto, uploadAudio } = useResponseMediaUpload({
userId: user?.id ?? "",
questionId,
});
const {
state: audioState,
durationSeconds,
recordingUri,
startRecording,
stopRecording,
resetRecording,
} = useAudioRecorder({ maxDurationSeconds });
const isBusy = isLoading || uploading;
// Reset state when modal closes // Reset state when modal closes
useEffect(() => { useEffect(() => {
@@ -67,18 +94,38 @@ export function EditResponseSheet({ visible, response, onClose, onSuccess }: Edi
setIsLoading(false); setIsLoading(false);
setPendingAnswer(null); setPendingAnswer(null);
setConfirmModalData(null); setConfirmModalData(null);
setTextValue("");
setSelectedWhoLikely(null);
resetRecording();
} }
}, [visible]); }, [visible, resetRecording]);
useEffect(() => {
if (!visible || !response) return;
if (response.response_data?.type === "text_answer") {
setTextValue(response.response_data.text);
} else {
setTextValue("");
}
if (response.response_data?.type === "who_likely") {
setSelectedWhoLikely(response.response_data.chosen_user_id);
} else {
setSelectedWhoLikely(null);
}
}, [visible, response]);
if (!response) return null; if (!response) return null;
const handleAnswerPress = async (newAnswer: AnswerType) => { const handleAnswerPress = async (newAnswer: AnswerType, responseData?: ResponseData | null) => {
if (newAnswer === response.answer || isLoading) return; if (isBusy) return;
if (newAnswer === response.answer && typeof responseData === "undefined") return;
setIsLoading(true); setIsLoading(true);
setPendingAnswer(newAnswer); setPendingAnswer(newAnswer);
const result = await updateResponse(response.question_id, newAnswer, false); const result = await updateResponse(response.question_id, newAnswer, false, responseData);
if (result.requires_confirmation && result.match_id) { if (result.requires_confirmation && result.match_id) {
// Show confirmation modal // Show confirmation modal
@@ -125,7 +172,76 @@ export function EditResponseSheet({ visible, response, onClose, onSuccess }: Edi
setPendingAnswer(null); setPendingAnswer(null);
}; };
const AnswerButton = ({ answer }: { answer: AnswerType }) => { const questionType = response.question.question_type ?? "swipe";
const isTextSaveDisabled = textValue.trim().length === 0 || isBusy;
const isNoSelected = response.answer === "no";
const userLabel = user?.name || "You";
const partnerLabel = partner?.name || "Partner";
const canUpload = !!user?.id && !!questionId;
const handleTextSave = async () => {
const trimmed = textValue.trim();
if (!trimmed) return;
await handleAnswerPress("yes", { type: "text_answer", text: trimmed });
};
const handleSetNo = async () => {
await handleAnswerPress("no", null);
};
const handleWhoLikelySelect = async (userId: string) => {
if (isBusy) return;
setSelectedWhoLikely(userId);
await handleAnswerPress("yes", { type: "who_likely", chosen_user_id: userId });
};
const handlePhotoPick = async (source: "camera" | "library") => {
if (isBusy || !canUpload) return;
const result = source === "camera" ? await takePhoto() : await pickMedia();
if (!result || result.mediaType !== "image") return;
const uploadResult = await uploadPhoto(result.uri);
if (!uploadResult.success || !uploadResult.mediaPath) {
console.error("Failed to upload photo response:", uploadResult.error);
return;
}
await handleAnswerPress("yes", { type: "photo", media_path: uploadResult.mediaPath });
};
const handleStartRecording = async () => {
if (isBusy) return;
await startRecording();
};
const handleStopRecording = async () => {
if (isBusy) return;
await stopRecording();
};
const handleAudioSave = async () => {
if (!recordingUri || isBusy || !canUpload) return;
const uploadResult = await uploadAudio(recordingUri, durationSeconds);
if (!uploadResult.success || !uploadResult.mediaPath) {
console.error("Failed to upload audio response:", uploadResult.error);
return;
}
await handleAnswerPress("yes", {
type: "audio",
media_path: uploadResult.mediaPath,
duration_seconds: durationSeconds,
});
resetRecording();
};
const handleDiscardRecording = () => {
if (isBusy) return;
resetRecording();
};
const AnswerButton = ({ answer }: { answer: SelectableAnswerType }) => {
const config = ANSWER_CONFIG[answer]; const config = ANSWER_CONFIG[answer];
const isSelected = response.answer === answer; const isSelected = response.answer === answer;
const isPending = pendingAnswer === answer; const isPending = pendingAnswer === answer;
@@ -140,7 +256,7 @@ export function EditResponseSheet({ visible, response, onClose, onSuccess }: Edi
}, },
]} ]}
onPress={() => handleAnswerPress(answer)} onPress={() => handleAnswerPress(answer)}
disabled={isLoading} disabled={isBusy}
activeOpacity={0.7} activeOpacity={0.7}
> >
{isPending && isLoading ? ( {isPending && isLoading ? (
@@ -169,6 +285,243 @@ export function EditResponseSheet({ visible, response, onClose, onSuccess }: Edi
); );
}; };
const ActionButton = ({
label,
icon,
color = ACCENT,
onPress,
disabled = false,
loading = false,
}: {
label: string;
icon: keyof typeof Ionicons.glyphMap;
color?: string;
onPress: () => void;
disabled?: boolean;
loading?: boolean;
}) => {
const isDisabled = disabled || loading;
const iconColor = isDisabled ? colors.textTertiary : color;
return (
<TouchableOpacity
style={[styles.actionButton, isDisabled && styles.actionButtonDisabled]}
onPress={onPress}
disabled={isDisabled}
activeOpacity={0.7}
>
{loading ? (
<ActivityIndicator color={iconColor} size="small" />
) : (
<Ionicons name={icon} size={20} color={iconColor} />
)}
<Text style={[styles.actionButtonText, { color: iconColor }]}>
{label}
</Text>
</TouchableOpacity>
);
};
const ChoiceButton = ({
label,
selected,
onPress,
disabled = false,
}: {
label: string;
selected: boolean;
onPress: () => void;
disabled?: boolean;
}) => (
<TouchableOpacity
style={[styles.choiceButton, selected && styles.choiceButtonSelected]}
onPress={onPress}
disabled={disabled}
activeOpacity={0.7}
>
<Text style={[styles.choiceButtonText, selected && styles.choiceButtonTextSelected]}>
{label}
</Text>
{selected && <Ionicons name="checkmark" size={16} color={ACCENT} />}
</TouchableOpacity>
);
const isSavingYes = isLoading && pendingAnswer === "yes";
const isSavingNo = isLoading && pendingAnswer === "no";
const actionContent = (() => {
if (questionType === "text_answer") {
return (
<View style={styles.editorContainer}>
<View style={styles.textInputContainer}>
<TextInput
style={styles.textInput}
value={textValue}
onChangeText={(value) => setTextValue(value.slice(0, MAX_TEXT_LENGTH))}
placeholder="Update your answer..."
placeholderTextColor={colors.textTertiary}
multiline
editable={!isBusy}
textAlignVertical="top"
maxLength={MAX_TEXT_LENGTH}
/>
<View style={styles.charCountContainer}>
<Text style={styles.charCount}>
{textValue.length}/{MAX_TEXT_LENGTH}
</Text>
</View>
</View>
<View style={styles.actionRow}>
<ActionButton
label="Set No"
icon="close"
color={ANSWER_CONFIG.no.color}
onPress={handleSetNo}
disabled={isBusy || isNoSelected}
loading={isSavingNo}
/>
<ActionButton
label="Save"
icon="checkmark"
color={ANSWER_CONFIG.yes.color}
onPress={handleTextSave}
disabled={isTextSaveDisabled}
loading={isSavingYes}
/>
</View>
</View>
);
}
if (questionType === "who_likely") {
return (
<View style={styles.editorContainer}>
<View style={styles.choiceRow}>
<ChoiceButton
label={userLabel}
selected={selectedWhoLikely === user?.id}
onPress={() => {
if (user?.id) {
handleWhoLikelySelect(user.id);
}
}}
disabled={isBusy || !user?.id}
/>
<ChoiceButton
label={partnerLabel}
selected={selectedWhoLikely === partner?.id}
onPress={() => {
if (partner?.id) {
handleWhoLikelySelect(partner.id);
}
}}
disabled={isBusy || !partner?.id}
/>
</View>
<View style={styles.actionRow}>
<ActionButton
label="Set No"
icon="close"
color={ANSWER_CONFIG.no.color}
onPress={handleSetNo}
disabled={isBusy || isNoSelected}
loading={isSavingNo}
/>
</View>
</View>
);
}
if (questionType === "photo") {
return (
<View style={styles.editorContainer}>
<View style={styles.actionRow}>
<ActionButton
label="Camera"
icon="camera"
onPress={() => handlePhotoPick("camera")}
disabled={isBusy || !canUpload}
loading={uploading}
/>
<ActionButton
label="Library"
icon="images"
onPress={() => handlePhotoPick("library")}
disabled={isBusy || !canUpload}
loading={uploading}
/>
</View>
<View style={styles.actionRow}>
<ActionButton
label="Set No"
icon="close"
color={ANSWER_CONFIG.no.color}
onPress={handleSetNo}
disabled={isBusy || isNoSelected}
loading={isSavingNo}
/>
</View>
</View>
);
}
if (questionType === "audio") {
const statusText = audioState === "recording"
? `Recording... ${durationSeconds}s`
: recordingUri
? `Recorded ${durationSeconds}s`
: "Tap record to start";
return (
<View style={styles.editorContainer}>
<Text style={styles.audioStatusText}>{statusText}</Text>
<View style={styles.actionRow}>
<ActionButton
label={audioState === "recording" ? "Stop" : recordingUri ? "Re-record" : "Record"}
icon={audioState === "recording" ? "square" : "mic"}
onPress={audioState === "recording" ? handleStopRecording : handleStartRecording}
disabled={isBusy}
/>
<ActionButton
label="Save"
icon="checkmark"
color={ANSWER_CONFIG.yes.color}
onPress={handleAudioSave}
disabled={!recordingUri || audioState === "recording" || isBusy || !canUpload}
loading={uploading || isSavingYes}
/>
</View>
{recordingUri && audioState !== "recording" && (
<TouchableOpacity
style={styles.linkButton}
onPress={handleDiscardRecording}
disabled={isBusy}
>
<Text style={styles.linkButtonText}>Discard recording</Text>
</TouchableOpacity>
)}
<View style={styles.actionRow}>
<ActionButton
label="Set No"
icon="close"
color={ANSWER_CONFIG.no.color}
onPress={handleSetNo}
disabled={isBusy || isNoSelected}
loading={isSavingNo}
/>
</View>
</View>
);
}
return (
<View style={styles.answerButtons}>
<AnswerButton answer="yes" />
<AnswerButton answer="no" />
</View>
);
})();
return ( return (
<Modal <Modal
visible={visible} visible={visible}
@@ -191,7 +544,7 @@ export function EditResponseSheet({ visible, response, onClose, onSuccess }: Edi
<BlurView intensity={80} tint="dark" style={styles.sheet}> <BlurView intensity={80} tint="dark" style={styles.sheet}>
<SheetContent <SheetContent
response={response} response={response}
AnswerButton={AnswerButton} actions={actionContent}
onClose={onClose} onClose={onClose}
/> />
</BlurView> </BlurView>
@@ -199,7 +552,7 @@ export function EditResponseSheet({ visible, response, onClose, onSuccess }: Edi
<View style={[styles.sheet, styles.sheetAndroid]}> <View style={[styles.sheet, styles.sheetAndroid]}>
<SheetContent <SheetContent
response={response} response={response}
AnswerButton={AnswerButton} actions={actionContent}
onClose={onClose} onClose={onClose}
/> />
</View> </View>
@@ -221,11 +574,11 @@ export function EditResponseSheet({ visible, response, onClose, onSuccess }: Edi
// Sheet content extracted for reuse between iOS blur and Android solid background // Sheet content extracted for reuse between iOS blur and Android solid background
function SheetContent({ function SheetContent({
response, response,
AnswerButton, actions,
onClose, onClose,
}: { }: {
response: ResponseWithQuestion; response: ResponseWithQuestion;
AnswerButton: React.FC<{ answer: AnswerType }>; actions: ReactNode;
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
@@ -261,12 +614,8 @@ function SheetContent({
</View> </View>
)} )}
{/* Answer buttons */} {/* Action content */}
<View style={styles.answerButtons}> <View style={styles.actionsContainer}>{actions}</View>
<AnswerButton answer="yes" />
<AnswerButton answer="maybe" />
<AnswerButton answer="no" />
</View>
{/* Bottom padding for safe area */} {/* Bottom padding for safe area */}
<View style={styles.bottomPadding} /> <View style={styles.bottomPadding} />
@@ -359,10 +708,36 @@ const styles = StyleSheet.create({
color: colors.warning, color: colors.warning,
flex: 1, flex: 1,
}, },
actionsContainer: {
marginBottom: spacing.lg,
},
editorContainer: {
gap: spacing.md,
},
textInputContainer: {
backgroundColor: "rgba(255, 255, 255, 0.08)",
borderRadius: radius.lg,
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
padding: spacing.md,
minHeight: 120,
},
textInput: {
...typography.body,
color: colors.text,
minHeight: 80,
},
charCountContainer: {
alignItems: "flex-end",
marginTop: spacing.xs,
},
charCount: {
...typography.caption2,
color: colors.textTertiary,
},
answerButtons: { answerButtons: {
flexDirection: "row", flexDirection: "row",
gap: spacing.md, gap: spacing.md,
marginBottom: spacing.lg,
}, },
answerButton: { answerButton: {
flex: 1, flex: 1,
@@ -391,6 +766,70 @@ const styles = StyleSheet.create({
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}, },
actionRow: {
flexDirection: "row",
gap: spacing.md,
alignItems: "center",
},
actionButton: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: spacing.md,
borderRadius: radius.md,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
gap: spacing.xs,
},
actionButtonDisabled: {
opacity: 0.6,
},
actionButtonText: {
...typography.caption1,
fontWeight: "600",
},
choiceRow: {
flexDirection: "row",
gap: spacing.md,
},
choiceButton: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: spacing.md,
borderRadius: radius.md,
backgroundColor: "rgba(255, 255, 255, 0.06)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
flexDirection: "row",
gap: spacing.xs,
},
choiceButtonSelected: {
borderColor: `${ACCENT_RGBA}0.6)`,
backgroundColor: `${ACCENT_RGBA}0.12)`,
},
choiceButtonText: {
...typography.callout,
color: colors.textSecondary,
fontWeight: "600",
},
choiceButtonTextSelected: {
color: ACCENT,
},
audioStatusText: {
...typography.caption1,
color: colors.textSecondary,
textAlign: "center",
},
linkButton: {
alignSelf: "center",
},
linkButtonText: {
...typography.caption1,
color: colors.textTertiary,
textDecorationLine: "underline",
},
bottomPadding: { bottomPadding: {
height: Platform.OS === "ios" ? 34 : 20, height: Platform.OS === "ios" ? 34 : 20,
}, },
@@ -8,8 +8,6 @@ import { colors, spacing, typography, radius } from "../../theme";
// Premium color palette // Premium color palette
const ACCENT = colors.premium.gold; const ACCENT = colors.premium.gold;
const ACCENT_RGBA = "rgba(212, 175, 55, "; const ACCENT_RGBA = "rgba(212, 175, 55, ";
const ROSE = colors.premium.rose;
const ROSE_RGBA = "rgba(232, 164, 174, ";
// Answer colors (semantic) // Answer colors (semantic)
const ANSWER_COLORS = { const ANSWER_COLORS = {
@@ -30,15 +28,97 @@ const ANSWER_COLORS = {
}, },
}; };
const RESPONSE_TYPE_CONFIG = {
text_answer: {
icon: "chatbubble-ellipses-outline" as const,
label: "TEXT",
},
photo: {
icon: "image-outline" as const,
label: "PHOTO",
},
audio: {
icon: "mic-outline" as const,
label: "AUDIO",
},
who_likely: {
icon: "people-outline" as const,
label: "WHO",
},
};
interface ResponseCardProps { interface ResponseCardProps {
response: ResponseWithQuestion; response: ResponseWithQuestion;
index: number; index: number;
onEditPress: () => void; onEditPress: () => void;
onChatPress?: () => void; onChatPress?: () => void;
viewerId?: string;
viewerName?: string | null;
partnerName?: string | null;
} }
export function ResponseCard({ response, index, onEditPress, onChatPress }: ResponseCardProps) { function truncateText(value: string, maxLength: number) {
if (value.length <= maxLength) return value;
return `${value.slice(0, maxLength - 3)}...`;
}
function getResponseSummary(
response: ResponseWithQuestion,
viewerId?: string,
viewerName?: string | null,
partnerName?: string | null
) {
const data = response.response_data;
if (!data) return null;
if (data.type === "text_answer") {
return `"${truncateText(data.text, 90)}"`;
}
if (data.type === "photo") {
return "Photo response saved";
}
if (data.type === "audio") {
const duration = data.duration_seconds ? `${data.duration_seconds}s` : "";
return duration ? `Audio response (${duration})` : "Audio response saved";
}
if (data.type === "who_likely") {
const chosenName = data.chosen_user_id === viewerId
? viewerName || "you"
: partnerName || "your partner";
return `You chose ${chosenName}`;
}
return null;
}
export function ResponseCard({
response,
index,
onEditPress,
onChatPress,
viewerId,
viewerName,
partnerName,
}: ResponseCardProps) {
const answerStyle = ANSWER_COLORS[response.answer]; const answerStyle = ANSWER_COLORS[response.answer];
const questionType = response.question.question_type ?? "swipe";
const responseSummary = getResponseSummary(response, viewerId, viewerName, partnerName);
const typeKey = response.response_data?.type || (questionType !== "swipe" ? questionType : null);
const typeConfig = typeKey ? RESPONSE_TYPE_CONFIG[typeKey as keyof typeof RESPONSE_TYPE_CONFIG] : null;
const showTypeBadge = questionType !== "swipe" && response.answer !== "no" && !!typeConfig;
const badgeColor = showTypeBadge ? ACCENT : answerStyle.color;
const badgeRgba = showTypeBadge ? ACCENT_RGBA : answerStyle.rgba;
const badgeIcon = showTypeBadge && typeConfig
? typeConfig.icon
: response.answer === "yes"
? "checkmark"
: response.answer === "no"
? "close"
: "help";
const tagLabel = showTypeBadge && typeConfig ? typeConfig.label : answerStyle.label;
return ( return (
<Animated.View entering={FadeInRight.delay(index * 30).duration(250)}> <Animated.View entering={FadeInRight.delay(index * 30).duration(250)}>
@@ -52,7 +132,7 @@ export function ResponseCard({ response, index, onEditPress, onChatPress }: Resp
/> />
{/* Top silk highlight */} {/* Top silk highlight */}
<LinearGradient <LinearGradient
colors={[`${answerStyle.rgba}0.06)`, "transparent"]} colors={[`${badgeRgba}0.06)`, "transparent"]}
style={styles.cardSilkHighlight} style={styles.cardSilkHighlight}
start={{ x: 0.5, y: 0 }} start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }} end={{ x: 0.5, y: 1 }}
@@ -63,19 +143,13 @@ export function ResponseCard({ response, index, onEditPress, onChatPress }: Resp
<View <View
style={[ style={[
styles.answerBadge, styles.answerBadge,
{ backgroundColor: `${answerStyle.rgba}0.15)`, borderColor: `${answerStyle.rgba}0.3)` }, { backgroundColor: `${badgeRgba}0.15)`, borderColor: `${badgeRgba}0.3)` },
]} ]}
> >
<Ionicons <Ionicons
name={ name={badgeIcon}
response.answer === "yes"
? "checkmark"
: response.answer === "no"
? "close"
: "help"
}
size={18} size={18}
color={answerStyle.color} color={badgeColor}
/> />
</View> </View>
@@ -84,6 +158,11 @@ export function ResponseCard({ response, index, onEditPress, onChatPress }: Resp
<Text style={styles.questionText} numberOfLines={2}> <Text style={styles.questionText} numberOfLines={2}>
{response.question.text} {response.question.text}
</Text> </Text>
{responseSummary && (
<Text style={styles.responseSummary} numberOfLines={2}>
{responseSummary}
</Text>
)}
<View style={styles.metaRow}> <View style={styles.metaRow}>
{/* Answer tag */} {/* Answer tag */}
@@ -91,13 +170,13 @@ export function ResponseCard({ response, index, onEditPress, onChatPress }: Resp
style={[ style={[
styles.tag, styles.tag,
{ {
backgroundColor: `${answerStyle.rgba}0.1)`, backgroundColor: `${badgeRgba}0.1)`,
borderColor: `${answerStyle.rgba}0.2)`, borderColor: `${badgeRgba}0.2)`,
}, },
]} ]}
> >
<Text style={[styles.tagText, { color: answerStyle.color }]}> <Text style={[styles.tagText, { color: badgeColor }]}>
{answerStyle.label} {tagLabel}
</Text> </Text>
</View> </View>
@@ -182,6 +261,11 @@ const styles = StyleSheet.create({
marginBottom: spacing.xs, marginBottom: spacing.xs,
lineHeight: 20, lineHeight: 20,
}, },
responseSummary: {
...typography.caption1,
color: colors.textSecondary,
marginBottom: spacing.xs,
},
metaRow: { metaRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -0,0 +1,534 @@
import { useRef, useEffect, useState } from "react";
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
Platform,
Linking,
Share,
useWindowDimensions,
Image,
} from "react-native";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
FadeIn,
FadeOut,
SlideInDown,
SlideOutDown,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
import * as FileSystem from "expo-file-system";
import { colors, gradients, radius, typography, spacing, blur } from "../../theme";
const logo = require("../../../assets/logo.png");
const MAX_CARD_WIDTH = 340;
interface SharePreviewModalProps {
visible: boolean;
onClose: () => void;
question: {
text: string;
partner_text?: string | null;
is_two_part?: boolean;
};
packName?: string;
cardColor?: string;
}
export function SharePreviewModal({
visible,
onClose,
question,
packName,
cardColor,
}: SharePreviewModalProps) {
const insets = useSafeAreaInsets();
const { width: screenWidth } = useWindowDimensions();
const cardWidth = Math.min(screenWidth - 64, MAX_CARD_WIDTH);
const shareViewRef = useRef<ViewShot>(null);
const [capturedUri, setCapturedUri] = useState<string | null>(null);
const [isCapturing, setIsCapturing] = useState(false);
// Capture the card when modal becomes visible
useEffect(() => {
if (visible && shareViewRef.current) {
setIsCapturing(true);
// Small delay to ensure the view is rendered
const timer = setTimeout(async () => {
try {
const uri = await shareViewRef.current?.capture?.();
if (uri) {
setCapturedUri(uri);
}
} catch (error) {
console.error('Error capturing share image:', error);
} finally {
setIsCapturing(false);
}
}, 100);
return () => clearTimeout(timer);
}
}, [visible]);
// Reset state when modal closes
useEffect(() => {
if (!visible) {
setCapturedUri(null);
}
}, [visible]);
const handleInstagramShare = async () => {
if (!capturedUri) return;
try {
// Check if Instagram is installed
const instagramUrl = 'instagram://app';
const canOpenInstagram = await Linking.canOpenURL(instagramUrl);
if (canOpenInstagram && Platform.OS === 'ios') {
// For iOS, we can use the Instagram Stories URL scheme
// First, we need to copy the image to a location Instagram can access
const base64 = await FileSystem.readAsStringAsync(capturedUri, {
encoding: FileSystem.EncodingType.Base64,
});
// Use Instagram Stories URL scheme
const instagramStoriesUrl = `instagram-stories://share?source_application=com.sauci.app`;
// Try to open Instagram Stories with the image
// Note: This requires the image to be in the pasteboard on iOS
await Linking.openURL(instagramStoriesUrl);
} else {
// Fallback to regular sharing
await handleMoreShare();
}
} catch (error) {
console.error('Instagram share error:', error);
// Fallback to regular sharing
await handleMoreShare();
}
};
const handleMessagesShare = async () => {
if (!capturedUri) {
// Fallback to text
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
return;
}
try {
// On iOS, we can share directly to Messages
if (Platform.OS === 'ios') {
await Sharing.shareAsync(capturedUri, {
mimeType: 'image/png',
UTI: 'public.png',
});
} else {
await handleMoreShare();
}
} catch (error) {
console.error('Messages share error:', error);
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
}
};
const handleMoreShare = async () => {
if (!capturedUri) {
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
return;
}
try {
const isAvailable = await Sharing.isAvailableAsync();
if (isAvailable) {
await Sharing.shareAsync(capturedUri, {
mimeType: 'image/png',
dialogTitle: 'Share this question',
});
} else {
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
}
} catch (error) {
console.error('Share error:', error);
}
};
const useBlur = Platform.OS === 'ios';
return (
<Modal
visible={visible}
transparent
animationType="none"
statusBarTranslucent
onRequestClose={onClose}
>
<View style={styles.overlay}>
{/* Backdrop */}
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={StyleSheet.absoluteFill}
>
{useBlur ? (
<BlurView
intensity={blur.heavy}
tint="dark"
style={StyleSheet.absoluteFill}
/>
) : (
<View style={styles.backdropFallback} />
)}
</Animated.View>
{/* Content */}
<Animated.View
entering={SlideInDown.springify().damping(20)}
exiting={SlideOutDown.duration(200)}
style={[styles.content, { paddingTop: insets.top }]}
>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
style={styles.closeButton}
onPress={onClose}
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
>
<Ionicons name="close" size={28} color={colors.text} />
</TouchableOpacity>
<Text style={styles.title}>Share</Text>
<View style={styles.headerSpacer} />
</View>
{/* Card Preview */}
<View style={styles.previewContainer}>
<ViewShot
ref={shareViewRef}
options={{
format: 'png',
quality: 1,
result: 'tmpfile',
}}
>
<ShareableCard
question={question}
packName={packName}
cardWidth={cardWidth}
cardColor={cardColor}
/>
</ViewShot>
</View>
{/* Share Actions */}
<View style={[styles.actionsContainer, { paddingBottom: insets.bottom + spacing.lg }]}>
<ShareActionButton
icon="logo-instagram"
label="Instagram"
colors={['#833AB4', '#FD1D1D', '#F77737']}
onPress={handleInstagramShare}
disabled={isCapturing}
/>
<ShareActionButton
icon="chatbubble-ellipses"
label="Messages"
colors={['#34C759', '#30D158']}
onPress={handleMessagesShare}
disabled={isCapturing}
/>
<ShareActionButton
icon="share-outline"
label="More"
colors={[colors.glass.backgroundLight, colors.glass.backgroundLight]}
onPress={handleMoreShare}
disabled={isCapturing}
isOutline
/>
</View>
</Animated.View>
</View>
</Modal>
);
}
function ShareActionButton({
icon,
label,
colors: buttonColors,
onPress,
disabled,
isOutline,
}: {
icon: string;
label: string;
colors: string[];
onPress: () => void;
disabled?: boolean;
isOutline?: boolean;
}) {
return (
<TouchableOpacity
style={styles.actionButton}
onPress={onPress}
disabled={disabled}
activeOpacity={0.7}
>
<View style={[
styles.actionIconContainer,
isOutline && styles.actionIconOutline,
]}>
{!isOutline && (
<LinearGradient
colors={buttonColors as [string, string, ...string[]]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
)}
<Ionicons
name={icon as any}
size={28}
color={colors.text}
/>
</View>
<Text style={styles.actionLabel}>{label}</Text>
</TouchableOpacity>
);
}
function ShareableCard({
question,
packName,
cardWidth,
cardColor,
}: {
question: {
text: string;
partner_text?: string | null;
is_two_part?: boolean;
};
packName?: string;
cardWidth: number;
cardColor?: string;
}) {
// Use the card color if provided, otherwise fall back to primary gradient
const useGradient = !cardColor;
return (
<View style={[styles.shareableCard, { width: cardWidth }]}>
{/* Background - solid color matching what the user saw, or gradient fallback */}
{useGradient ? (
<LinearGradient
colors={gradients.primary as [string, string]}
style={[StyleSheet.absoluteFill, { borderRadius: radius.xl }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
) : (
<View
style={[
StyleSheet.absoluteFill,
{ backgroundColor: cardColor, borderRadius: radius.xl }
]}
/>
)}
{/* Subtle overlay for depth */}
<View style={styles.cardOverlay} />
{/* Pack name badge */}
{packName && (
<View style={styles.packBadge}>
<Text style={styles.packBadgeText}>{packName}</Text>
</View>
)}
{/* Content */}
<View style={styles.shareableContent}>
<Text style={[styles.questionText, question.is_two_part && styles.questionTextSmaller]}>
{question.text}
</Text>
{/* Partner's version of the question */}
{question.partner_text && (
<View style={styles.partnerSection}>
<View style={styles.partnerDivider}>
<View style={styles.partnerDividerLine} />
<Text style={styles.partnerLabel}>Partner sees</Text>
<View style={styles.partnerDividerLine} />
</View>
<Text style={styles.partnerText}>{question.partner_text}</Text>
</View>
)}
</View>
{/* Branding with logo */}
<View style={styles.branding}>
<Image source={logo} style={styles.brandingLogo} />
<Text style={styles.brandingText}>sauci.app</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
backdropFallback: {
flex: 1,
backgroundColor: 'rgba(13, 13, 26, 0.95)',
},
content: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
closeButton: {
width: 44,
height: 44,
justifyContent: 'center',
alignItems: 'center',
},
title: {
...typography.headline,
color: colors.text,
fontWeight: '600',
},
headerSpacer: {
width: 44,
},
previewContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: spacing.lg,
},
actionsContainer: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.xl,
paddingTop: spacing.xl,
paddingHorizontal: spacing.lg,
},
actionButton: {
alignItems: 'center',
gap: spacing.sm,
},
actionIconContainer: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
},
actionIconOutline: {
backgroundColor: colors.glass.backgroundLight,
borderWidth: 1,
borderColor: colors.glass.border,
},
actionLabel: {
...typography.caption1,
color: colors.text,
fontWeight: '500',
},
// Shareable card styles
shareableCard: {
aspectRatio: 0.7,
borderRadius: radius.xl,
overflow: 'hidden',
padding: spacing.lg,
},
cardOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.1)',
},
packBadge: {
alignSelf: 'flex-start',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
packBadgeText: {
...typography.caption1,
color: colors.text,
fontWeight: '600',
},
shareableContent: {
flex: 1,
justifyContent: 'center',
paddingVertical: spacing.xl,
},
questionText: {
...typography.title1,
color: colors.text,
fontWeight: '700',
lineHeight: 36,
},
questionTextSmaller: {
...typography.title2,
lineHeight: 30,
},
partnerSection: {
marginTop: spacing.lg,
},
partnerDivider: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
gap: spacing.sm,
},
partnerDividerLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
},
partnerLabel: {
...typography.caption1,
color: 'rgba(255, 255, 255, 0.7)',
fontWeight: '500',
},
partnerText: {
...typography.subhead,
color: 'rgba(255, 255, 255, 0.8)',
fontStyle: 'italic',
},
branding: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
},
brandingLogo: {
width: 24,
height: 24,
resizeMode: 'contain',
},
brandingText: {
...typography.subhead,
color: colors.text,
fontWeight: '600',
},
});
@@ -0,0 +1 @@
export { SharePreviewModal } from './SharePreviewModal';
@@ -1,678 +0,0 @@
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, useWindowDimensions, TouchableOpacity as RNTouchableOpacity } from "react-native";
import { Gesture, GestureDetector, TouchableOpacity } from "react-native-gesture-handler";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
runOnJS,
interpolate,
Extrapolate,
withTiming,
interpolateColor,
Easing,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../../theme";
import { QuestionFeedbackModal } from "../feedback";
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
const MAX_CARD_WIDTH = 400;
const SWIPE_THRESHOLD = 100;
interface Props {
question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean; partner_answered?: boolean };
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
}
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
export default function SwipeCard({ question, onSwipe }: Props) {
const { width: screenWidth } = useWindowDimensions();
const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH);
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const rotation = useSharedValue(0);
const scale = useSharedValue(1);
const shadowExpand = useSharedValue(0);
const idleBreathing = useSharedValue(0);
const isGesturing = useSharedValue(false);
const context = useSharedValue({ x: 0, y: 0 });
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const gesture = Gesture.Pan()
.activeOffsetX([-15, 15])
.activeOffsetY([-15, 15])
.onStart(() => {
context.value = { x: translateX.value, y: translateY.value };
scale.value = withTiming(1.02, { duration: animations.timing.fast });
shadowExpand.value = withTiming(1, { duration: animations.timing.fast });
isGesturing.value = true;
})
.onUpdate((event) => {
translateX.value = event.translationX + context.value.x;
translateY.value = event.translationY + context.value.y;
rotation.value = interpolate(
translateX.value,
[-cardWidth / 2, cardWidth / 2],
[-12, 12],
Extrapolate.CLAMP
);
})
.onEnd(() => {
scale.value = withTiming(1, { duration: animations.timing.fast });
shadowExpand.value = withTiming(0, { duration: animations.timing.fast });
isGesturing.value = false;
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
const direction = translateX.value > 0 ? "right" : "left";
runOnJS(triggerHaptic)('medium');
translateX.value = withSpring(
direction === "right" ? screenWidth * 1.5 : -screenWidth * 1.5,
animations.springGentle
);
runOnJS(onSwipe)(direction);
} else if (translateY.value < -SWIPE_THRESHOLD) {
runOnJS(triggerHaptic)('medium');
translateY.value = withSpring(-screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("up");
} else if (translateY.value > SWIPE_THRESHOLD) {
runOnJS(triggerHaptic)('light');
translateY.value = withSpring(screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("down");
} else {
translateX.value = withSpring(0, animations.spring);
translateY.value = withSpring(0, animations.spring);
rotation.value = withSpring(0, animations.spring);
}
});
const animatedStyle = useAnimatedStyle(() => {
// Apply idle breathing only when card is at rest
const idleOffset = isGesturing.value ? 0 : interpolate(idleBreathing.value, [0, 1], [0, -6]);
return {
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value + idleOffset },
{ rotate: `${rotation.value}deg` },
{ scale: scale.value },
],
};
});
// Dynamic shadow for card lift effect
const cardShadowStyle = useAnimatedStyle(() => ({
shadowOpacity: interpolate(shadowExpand.value, [0, 1], [0.3, 0.5]),
shadowRadius: interpolate(shadowExpand.value, [0, 1], [24, 36]),
shadowOffset: {
width: 0,
height: interpolate(shadowExpand.value, [0, 1], [12, 18]),
},
}));
const overlayStyle = (direction: "left" | "right" | "up" | "down") =>
useAnimatedStyle(() => {
let opacity = 0;
let backgroundColor = 'transparent';
if (direction === "right") {
opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(46, 204, 113, 0.4)']
) as string;
} else if (direction === "left") {
opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(231, 76, 60, 0.4)']
) as string;
} else if (direction === "up") {
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(243, 156, 18, 0.4)']
) as string;
} else {
opacity = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(108, 117, 125, 0.4)']
) as string;
}
return {
opacity,
backgroundColor,
};
});
const handleButtonPress = async (direction: "left" | "right" | "up") => {
await triggerHaptic('light');
onSwipe(direction);
};
const useBlur = Platform.OS === 'ios';
const handleFeedbackPress = () => {
setShowFeedbackModal(true);
};
return (
<>
<View style={[styles.cardWrapper, { width: cardWidth }]}>
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.cardOuter, animatedStyle, cardShadowStyle, { shadowColor: '#000' }]}>
{useBlur ? (
<BlurView
intensity={blur.medium}
tint="dark"
style={styles.blurContainer}
>
<CardContent
question={question}
overlayStyle={overlayStyle}
handleButtonPress={handleButtonPress}
onFeedbackPress={handleFeedbackPress}
translateX={translateX}
translateY={translateY}
/>
</BlurView>
) : (
<View style={styles.fallbackContainer}>
<CardContent
question={question}
overlayStyle={overlayStyle}
handleButtonPress={handleButtonPress}
onFeedbackPress={handleFeedbackPress}
translateX={translateX}
translateY={translateY}
/>
</View>
)}
{/* Inner border highlight for depth */}
<View style={styles.innerBorder} pointerEvents="none" />
</Animated.View>
</GestureDetector>
</View>
<QuestionFeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
questionId={question.id}
questionText={question.text}
/>
</>
);
}
function CardContent({
question,
overlayStyle,
handleButtonPress,
onFeedbackPress,
translateX,
translateY,
}: {
question: Props['question'];
overlayStyle: (direction: "left" | "right" | "up" | "down") => any;
handleButtonPress: (direction: "left" | "right" | "up") => void;
onFeedbackPress: () => void;
translateX: Animated.SharedValue<number>;
translateY: Animated.SharedValue<number>;
}) {
const isHighIntensity = question.intensity >= 3;
// Badge scale animation based on swipe progress
const badgeScaleStyle = (direction: "left" | "right" | "up" | "down") =>
useAnimatedStyle(() => {
let progress = 0;
if (direction === "right") {
progress = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1], Extrapolate.CLAMP);
} else if (direction === "left") {
progress = interpolate(translateX.value, [-SWIPE_THRESHOLD, 0], [1, 0], Extrapolate.CLAMP);
} else if (direction === "up") {
progress = interpolate(translateY.value, [-SWIPE_THRESHOLD, 0], [1, 0], Extrapolate.CLAMP);
} else {
progress = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 1], Extrapolate.CLAMP);
}
return {
transform: [{ scale: interpolate(progress, [0, 0.5, 1], [0.8, 1, 1.1]) }],
};
});
return (
<>
{/* Top highlight */}
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
pointerEvents="none"
/>
{/* Left edge highlight */}
<LinearGradient
colors={['rgba(255,255,255,0.08)', 'transparent']}
style={styles.edgeHighlightLeft}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
pointerEvents="none"
/>
{/* Right edge highlight */}
<LinearGradient
colors={['transparent', 'rgba(255,255,255,0.08)']}
style={styles.edgeHighlightRight}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
pointerEvents="none"
/>
{/* Feedback Button */}
<RNTouchableOpacity
style={styles.feedbackButton}
onPress={onFeedbackPress}
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
activeOpacity={0.7}
>
<Ionicons
name="flag-outline"
size={22}
color={colors.textTertiary}
/>
</RNTouchableOpacity>
{/* Overlays with gradient badges */}
<Animated.View style={[styles.overlay, overlayStyle("right")]} pointerEvents="none">
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("right")]}>
<LinearGradient
colors={gradients.success as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Text style={styles.overlayText}>YES</Text>
</Animated.View>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("left")]} pointerEvents="none">
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("left")]}>
<LinearGradient
colors={gradients.error as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Text style={styles.overlayText}>NO</Text>
</Animated.View>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("up")]} pointerEvents="none">
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("up")]}>
<LinearGradient
colors={gradients.warning as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Text style={styles.overlayText}>MAYBE</Text>
</Animated.View>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("down")]} pointerEvents="none">
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("down")]}>
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.textTertiary }]} />
<Text style={styles.overlayText}>SKIP</Text>
</Animated.View>
</Animated.View>
{/* Content */}
<View style={styles.content}>
<View style={[
styles.intensityContainer,
isHighIntensity && styles.intensityContainerHigh
]}>
{[...Array(question.intensity)].map((_, i) => (
<Ionicons key={i} name="flame" size={16} color={colors.primary} />
))}
</View>
<Text style={[styles.text, question.is_two_part && styles.twoPartText]}>
{question.text}
</Text>
{/* Partner's version of the question - only show when user is answering first */}
{question.partner_text && !question.partner_answered && (
<View style={styles.partnerTextContainer}>
<View style={styles.partnerDivider}>
<View style={styles.partnerDividerLine} />
<View style={styles.partnerLabelContainer}>
<Ionicons name="swap-horizontal" size={12} color={colors.secondary} />
<Text style={styles.partnerLabel}>Partner sees</Text>
</View>
<View style={styles.partnerDividerLine} />
</View>
<Text style={styles.partnerText}>{question.partner_text}</Text>
</View>
)}
</View>
{/* Action Buttons */}
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<ActionButton
onPress={() => handleButtonPress("left")}
colors={gradients.error as [string, string]}
icon="close"
/>
<ActionButton
onPress={() => handleButtonPress("up")}
colors={gradients.warning as [string, string]}
icon="help"
small
/>
<ActionButton
onPress={() => handleButtonPress("right")}
colors={gradients.success as [string, string]}
icon="heart"
/>
</View>
</View>
</>
);
}
function ActionButton({
onPress,
colors: buttonColors,
icon,
small = false,
}: {
onPress: () => void;
colors: [string, string];
icon: string;
small?: boolean;
}) {
const buttonScale = useSharedValue(1);
const buttonGlow = useSharedValue(0);
const handlePressIn = () => {
buttonScale.value = withSpring(0.92, animations.spring);
buttonGlow.value = withTiming(1, { duration: 150 });
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
buttonGlow.value = withTiming(0, { duration: 200 });
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
shadowOpacity: interpolate(buttonGlow.value, [0, 1], [0.3, 0.6]),
shadowRadius: interpolate(buttonGlow.value, [0, 1], [8, 16]),
}));
return (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
>
<Animated.View style={[
styles.actionButton,
small && styles.actionButtonSmall,
buttonAnimatedStyle,
{ shadowColor: buttonColors[0] }
]}>
<LinearGradient
colors={buttonColors}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<LinearGradient
colors={['rgba(255,255,255,0.25)', 'rgba(255,255,255,0.05)']}
style={styles.buttonHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<Ionicons
name={icon as any}
size={small ? 20 : 28}
color={colors.text}
style={styles.buttonIcon}
/>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
position: "absolute",
maxWidth: MAX_CARD_WIDTH,
height: 480,
},
cardOuter: {
width: "100%",
height: "100%",
borderRadius: radius.xxl,
overflow: "hidden",
},
blurContainer: {
flex: 1,
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
borderRadius: radius.xxl,
overflow: "hidden",
},
fallbackContainer: {
flex: 1,
backgroundColor: colors.glass.backgroundLight,
borderWidth: 1,
borderColor: colors.glass.border,
borderRadius: radius.xxl,
overflow: "hidden",
},
highlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 80,
},
edgeHighlightLeft: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: 60,
},
edgeHighlightRight: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
width: 60,
},
innerBorder: {
position: 'absolute',
top: 2,
left: 2,
right: 2,
bottom: 2,
borderRadius: radius.xxl - 2,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.06)',
},
feedbackButton: {
position: 'absolute',
top: spacing.md,
right: spacing.md,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.glass.background,
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
elevation: 999,
borderWidth: 1,
borderColor: colors.glass.border,
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
intensityContainer: {
flexDirection: "row",
marginBottom: spacing.lg,
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: 2,
},
intensityContainerHigh: {
borderWidth: 1,
borderColor: colors.primary,
...shadows.glow(colors.primaryGlow),
},
text: {
...typography.title2,
color: colors.text,
textAlign: "center",
lineHeight: 32,
},
twoPartText: {
...typography.title3,
lineHeight: 28,
},
partnerTextContainer: {
marginTop: spacing.lg,
width: '100%',
paddingHorizontal: spacing.sm,
},
partnerDivider: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
partnerDividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.glass.border,
},
partnerLabelContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
backgroundColor: colors.secondaryLight,
borderRadius: radius.full,
gap: spacing.xs,
},
partnerLabel: {
...typography.caption1,
color: colors.secondary,
fontWeight: '500',
letterSpacing: 0.3,
},
partnerText: {
...typography.subhead,
color: colors.textSecondary,
textAlign: 'center',
fontStyle: 'italic',
},
footer: {
paddingBottom: spacing.lg,
paddingHorizontal: spacing.lg,
},
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderRadius: radius.xxl,
},
overlayBadge: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.md,
transform: [{ rotate: "-15deg" }],
overflow: 'hidden',
},
overlayText: {
fontSize: 36,
fontWeight: "900",
color: colors.text,
letterSpacing: 2,
},
buttonContainer: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: spacing.md,
},
actionButton: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
},
actionButtonSmall: {
width: 52,
height: 52,
borderRadius: 26,
},
buttonHighlight: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "50%",
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
},
buttonIcon: {
zIndex: 1,
},
});
@@ -1,17 +0,0 @@
/**
* SwipeCard component with platform-specific implementations.
*
* - On native (iOS/Android): Uses react-native-gesture-handler with Reanimated
* for smooth, native-level gesture performance (SwipeCard.native.tsx)
*
* - On web: Uses React Native's built-in PanResponder which is compatible with
* react-native-web (SwipeCard.web.tsx)
*
* Metro bundler automatically picks the right file based on the platform:
* - .native.tsx for iOS/Android
* - .web.tsx for web
*
* This file is a fallback that re-exports the web version for any other platform
* that might not have a specific implementation.
*/
export { default } from "./SwipeCard.web";
@@ -1,478 +0,0 @@
import { useState, useRef } from "react";
import { View, Text, StyleSheet, Animated, PanResponder, TouchableOpacity, useWindowDimensions } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { QuestionFeedbackModal } from "../feedback";
const MAX_CARD_WIDTH = 400;
const SWIPE_THRESHOLD = 100;
interface Props {
question: { id: string; text: string; intensity: number; partner_text?: string | null; partner_answered?: boolean };
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
}
/**
* Web-compatible SwipeCard using React Native's built-in PanResponder
* instead of react-native-gesture-handler which doesn't work on web.
*/
export default function SwipeCard({ question, onSwipe }: Props) {
const { width: screenWidth } = useWindowDimensions();
const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH);
const [dismissed, setDismissed] = useState(false);
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const translateX = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(0)).current;
const scale = useRef(new Animated.Value(1)).current;
const rotation = translateX.interpolate({
inputRange: [-cardWidth / 2, cardWidth / 2],
outputRange: ["-15deg", "15deg"],
extrapolate: "clamp",
});
const rightOverlayOpacity = translateX.interpolate({
inputRange: [0, SWIPE_THRESHOLD],
outputRange: [0, 1],
extrapolate: "clamp",
});
const leftOverlayOpacity = translateX.interpolate({
inputRange: [-SWIPE_THRESHOLD, 0],
outputRange: [1, 0],
extrapolate: "clamp",
});
const upOverlayOpacity = translateY.interpolate({
inputRange: [-SWIPE_THRESHOLD, 0],
outputRange: [1, 0],
extrapolate: "clamp",
});
const downOverlayOpacity = translateY.interpolate({
inputRange: [0, SWIPE_THRESHOLD],
outputRange: [0, 1],
extrapolate: "clamp",
});
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: (_, gestureState) => {
// Don't activate if touch is in footer area (bottom 100px)
const CARD_HEIGHT = 500;
const FOOTER_START_Y = CARD_HEIGHT - 100;
return gestureState.y0 < FOOTER_START_Y;
},
onMoveShouldSetPanResponder: (_, gestureState) => {
// Don't activate if touch is in footer area
const CARD_HEIGHT = 500;
const FOOTER_START_Y = CARD_HEIGHT - 100;
return gestureState.y0 < FOOTER_START_Y && (Math.abs(gestureState.dx) > 5 || Math.abs(gestureState.dy) > 5);
},
onPanResponderGrant: () => {
Animated.timing(scale, {
toValue: 1.05,
duration: 100,
useNativeDriver: true,
}).start();
},
onPanResponderMove: (_, gestureState) => {
translateX.setValue(gestureState.dx);
translateY.setValue(gestureState.dy);
},
onPanResponderRelease: (_, gestureState) => {
Animated.timing(scale, {
toValue: 1,
duration: 100,
useNativeDriver: true,
}).start();
if (Math.abs(gestureState.dx) > SWIPE_THRESHOLD) {
// Horizontal swipe
const direction = gestureState.dx > 0 ? "right" : "left";
const targetX = direction === "right" ? screenWidth * 1.5 : -screenWidth * 1.5;
Animated.spring(translateX, {
toValue: targetX,
useNativeDriver: true,
}).start(() => {
setDismissed(true);
onSwipe(direction);
});
} else if (gestureState.dy < -SWIPE_THRESHOLD) {
// Vertical swipe up
Animated.spring(translateY, {
toValue: -screenWidth * 1.5,
useNativeDriver: true,
}).start(() => {
setDismissed(true);
onSwipe("up");
});
} else if (gestureState.dy > SWIPE_THRESHOLD) {
// Vertical swipe down - skip
Animated.spring(translateY, {
toValue: screenWidth * 1.5,
useNativeDriver: true,
}).start(() => {
setDismissed(true);
onSwipe("down");
});
} else {
// Reset position
Animated.parallel([
Animated.spring(translateX, {
toValue: 0,
useNativeDriver: true,
}),
Animated.spring(translateY, {
toValue: 0,
useNativeDriver: true,
}),
]).start();
}
},
})
).current;
if (dismissed) {
return null;
}
return (
<>
<Animated.View
style={[
styles.card,
webStyles.card as any,
{
width: cardWidth,
transform: [
{ translateX },
{ translateY },
{ rotate: rotation },
{ scale },
],
},
]}
{...panResponder.panHandlers}
>
{/* Feedback Button */}
<TouchableOpacity
style={styles.feedbackButton}
onPress={() => setShowFeedbackModal(true)}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={18} color="#888" />
</TouchableOpacity>
{/* Overlays */}
<Animated.View
style={[
styles.overlay,
{ opacity: rightOverlayOpacity, backgroundColor: "rgba(76, 175, 80, 0.4)" },
]}
>
<Text style={styles.overlayText}>YES</Text>
</Animated.View>
<Animated.View
style={[
styles.overlay,
{ opacity: leftOverlayOpacity, backgroundColor: "rgba(244, 67, 54, 0.4)" },
]}
>
<Text style={styles.overlayText}>NO</Text>
</Animated.View>
<Animated.View
style={[
styles.overlay,
{ opacity: upOverlayOpacity, backgroundColor: "rgba(255, 152, 0, 0.4)" },
]}
>
<Text style={styles.overlayText}>MAYBE</Text>
</Animated.View>
<Animated.View
style={[
styles.overlay,
{ opacity: downOverlayOpacity, backgroundColor: "rgba(108, 117, 125, 0.4)" },
]}
>
<Text style={styles.overlayText}>SKIP</Text>
</Animated.View>
<View style={styles.content}>
<View style={styles.intensityContainer}>
{[...Array(question.intensity)].map((_, i) => (
<Ionicons key={i} name="flame" size={16} color="#e94560" />
))}
</View>
<Text style={[styles.text, question.partner_text ? styles.twoPartText : null]}>{question.text}</Text>
{/* Partner's version of the question - only show when user is answering first */}
{question.partner_text && !question.partner_answered && (
<View style={styles.partnerTextContainer}>
<View style={styles.partnerDivider}>
<View style={styles.partnerDividerLine} />
<View style={styles.partnerLabelContainer}>
<Ionicons name="swap-horizontal" size={12} color="#9b59b6" />
<Text style={styles.partnerLabel}>Partner sees</Text>
</View>
<View style={styles.partnerDividerLine} />
</View>
<Text style={styles.partnerText}>{question.partner_text}</Text>
</View>
)}
</View>
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[
styles.actionButton,
styles.noButton,
hoveredButton === "no" && styles.buttonHovered,
]}
onPress={() => onSwipe("left")}
// @ts-expect-error - web-only props
onMouseEnter={() => setHoveredButton("no")}
onMouseLeave={() => setHoveredButton(null)}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="thumbs-down" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
<TouchableOpacity
style={[
styles.actionButton,
styles.maybeButton,
hoveredButton === "maybe" && styles.buttonHovered,
]}
onPress={() => onSwipe("up")}
// @ts-expect-error - web-only props
onMouseEnter={() => setHoveredButton("maybe")}
onMouseLeave={() => setHoveredButton(null)}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="help-circle" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
<TouchableOpacity
style={[
styles.actionButton,
styles.yesButton,
hoveredButton === "yes" && styles.buttonHovered,
]}
onPress={() => onSwipe("right")}
// @ts-expect-error - web-only props
onMouseEnter={() => setHoveredButton("yes")}
onMouseLeave={() => setHoveredButton(null)}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="thumbs-up" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
</View>
</View>
</Animated.View>
<QuestionFeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
questionId={question.id}
questionText={question.text}
/>
</>
);
}
// Separate web-only styles that TypeScript doesn't recognize
const webStyles = {
card: { cursor: "grab" as const },
actionButton: { transition: "transform 0.2s ease, box-shadow 0.2s ease" },
};
const styles = StyleSheet.create({
card: {
position: "absolute",
height: 500,
maxWidth: MAX_CARD_WIDTH,
backgroundColor: "#16213e",
borderRadius: 24,
borderWidth: 1,
borderColor: "#0f3460",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 4.65,
elevation: 8,
overflow: "hidden",
},
feedbackButton: {
position: 'absolute',
top: 12,
right: 12,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 5,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.2)',
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 32,
},
intensityContainer: {
flexDirection: "row",
marginBottom: 24,
backgroundColor: "rgba(233, 69, 96, 0.1)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
},
text: {
fontSize: 24,
fontWeight: "bold",
color: "#fff",
textAlign: "center",
lineHeight: 32,
},
twoPartText: {
fontSize: 20,
lineHeight: 28,
},
footer: {
paddingTop: 8,
paddingBottom: 16,
paddingHorizontal: 8,
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.2)",
},
hint: {
color: "#666",
fontSize: 12,
marginBottom: 4,
},
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
},
overlayText: {
fontSize: 48,
fontWeight: "900",
color: "#fff",
textShadowColor: "rgba(0,0,0,0.5)",
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
transform: [{ rotate: "-15deg" }],
borderWidth: 4,
borderColor: "#fff",
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 12,
},
partnerTextContainer: {
marginTop: 24,
width: "100%",
paddingHorizontal: 8,
},
partnerDivider: {
flexDirection: "row",
alignItems: "center",
marginBottom: 8,
},
partnerDividerLine: {
flex: 1,
height: 1,
backgroundColor: "rgba(255, 255, 255, 0.08)",
},
partnerLabelContainer: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 4,
backgroundColor: "rgba(155, 89, 182, 0.15)",
borderRadius: 9999,
gap: 4,
},
partnerLabel: {
fontSize: 12,
color: "#9b59b6",
fontWeight: "500",
letterSpacing: 0.3,
},
partnerText: {
fontSize: 14,
color: "rgba(255, 255, 255, 0.7)",
textAlign: "center",
fontStyle: "italic",
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 32,
paddingVertical: 16,
gap: 20,
},
actionButton: {
alignItems: "center",
justifyContent: "center",
width: 64,
height: 64,
borderRadius: 32,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 8,
elevation: 8,
borderWidth: 2,
overflow: "hidden",
},
noButton: {
backgroundColor: "#E74C3C",
borderColor: "rgba(255, 255, 255, 0.3)",
},
maybeButton: {
backgroundColor: "#F39C12",
borderColor: "rgba(255, 255, 255, 0.3)",
},
yesButton: {
backgroundColor: "#2ECC71",
borderColor: "rgba(255, 255, 255, 0.3)",
},
buttonInnerGlow: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "50%",
backgroundColor: "rgba(255, 255, 255, 0.15)",
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
},
buttonIcon: {
zIndex: 1,
textShadowColor: "rgba(0, 0, 0, 0.3)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
buttonHovered: {
transform: [{ scale: 1.1 }],
shadowOpacity: 0.6,
shadowRadius: 12,
},
});
@@ -1,587 +0,0 @@
/**
* SwipeCardPremium - PoC of premium/boutique styling for the swipe experience
*
* Inspired by the "Coming Soon" Ann Summers aesthetic
*/
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, useWindowDimensions, Pressable } from "react-native";
import { Gesture, GestureDetector, TouchableOpacity } from "react-native-gesture-handler";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
runOnJS,
interpolate,
Extrapolate,
withTiming,
interpolateColor,
Easing,
FadeIn,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, featureColors, radius, shadows, blur, typography, spacing, animations } from "../../theme";
// Use feature colors for consistency
const { accent: ACCENT } = featureColors.dares;
const { accent: ROSE } = featureColors.quiz;
// RGBA helpers for custom opacity levels (derived from theme colors)
// Gold: #D4AF37 = rgb(212, 175, 55)
// Rose: #E8A4AE = rgb(232, 164, 174)
const rgba = {
gold: (opacity: number) => `rgba(212, 175, 55, ${opacity})`,
rose: (opacity: number) => `rgba(232, 164, 174, ${opacity})`,
};
// Haptics helper
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
const MAX_CARD_WIDTH = 400;
const SWIPE_THRESHOLD = 100;
interface Props {
question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean; partner_answered?: boolean };
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
onReport?: () => void;
}
export default function SwipeCardPremium({ question, onSwipe, onReport }: Props) {
const { width: screenWidth } = useWindowDimensions();
const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const rotation = useSharedValue(0);
const scale = useSharedValue(1);
const shadowExpand = useSharedValue(0);
const idleBreathing = useSharedValue(0);
const isGesturing = useSharedValue(false);
const context = useSharedValue({ x: 0, y: 0 });
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const gesture = Gesture.Pan()
.activeOffsetX([-15, 15])
.activeOffsetY([-15, 15])
.onStart(() => {
context.value = { x: translateX.value, y: translateY.value };
scale.value = withTiming(1.02, { duration: animations.timing.fast });
shadowExpand.value = withTiming(1, { duration: animations.timing.fast });
isGesturing.value = true;
})
.onUpdate((event) => {
translateX.value = event.translationX + context.value.x;
translateY.value = event.translationY + context.value.y;
rotation.value = interpolate(
translateX.value,
[-cardWidth / 2, cardWidth / 2],
[-8, 8], // Reduced rotation for elegance
Extrapolate.CLAMP
);
})
.onEnd(() => {
scale.value = withTiming(1, { duration: animations.timing.fast });
shadowExpand.value = withTiming(0, { duration: animations.timing.fast });
isGesturing.value = false;
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
const direction = translateX.value > 0 ? "right" : "left";
runOnJS(triggerHaptic)('medium');
translateX.value = withSpring(
direction === "right" ? screenWidth * 1.5 : -screenWidth * 1.5,
animations.springGentle
);
runOnJS(onSwipe)(direction);
} else if (translateY.value < -SWIPE_THRESHOLD) {
runOnJS(triggerHaptic)('medium');
translateY.value = withSpring(-screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("up");
} else if (translateY.value > SWIPE_THRESHOLD) {
runOnJS(triggerHaptic)('light');
translateY.value = withSpring(screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("down");
} else {
translateX.value = withSpring(0, animations.spring);
translateY.value = withSpring(0, animations.spring);
rotation.value = withSpring(0, animations.spring);
}
});
const animatedStyle = useAnimatedStyle(() => {
const idleOffset = isGesturing.value ? 0 : interpolate(idleBreathing.value, [0, 1], [0, -4]);
return {
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value + idleOffset },
{ rotate: `${rotation.value}deg` },
{ scale: scale.value },
],
};
});
const cardShadowStyle = useAnimatedStyle(() => ({
shadowOpacity: interpolate(shadowExpand.value, [0, 1], [0.2, 0.4]),
shadowRadius: interpolate(shadowExpand.value, [0, 1], [20, 32]),
shadowOffset: {
width: 0,
height: interpolate(shadowExpand.value, [0, 1], [10, 16]),
},
}));
const overlayStyle = (direction: "left" | "right" | "up" | "down") =>
useAnimatedStyle(() => {
let opacity = 0;
if (direction === "right") {
opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1]);
} else if (direction === "left") {
opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 1]);
} else if (direction === "up") {
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 1]);
} else {
opacity = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 1]);
}
return { opacity };
});
const handleButtonPress = async (direction: "left" | "right" | "up") => {
await triggerHaptic('light');
onSwipe(direction);
};
return (
<View style={[styles.cardWrapper, { width: cardWidth }]}>
{/* Report Button - outside GestureDetector to receive touches */}
{onReport && (
<Pressable
style={styles.reportButton}
onPress={onReport}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="flag-outline" size={16} color={colors.textTertiary} />
</Pressable>
)}
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.cardOuter, animatedStyle, cardShadowStyle, { shadowColor: ACCENT }]}>
{/* Premium dark background */}
<View style={styles.cardBackground}>
{/* Subtle gradient overlay */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
style={StyleSheet.absoluteFill}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[rgba.gold(0.08), 'transparent']}
style={styles.silkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Swipe Overlays - Premium style */}
<Animated.View style={[styles.overlay, styles.overlayYes, overlayStyle("right")]} pointerEvents="none">
<View style={styles.overlayBadgePremium}>
<Text style={styles.overlayTextPremium}>YES</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, styles.overlayNo, overlayStyle("left")]} pointerEvents="none">
<View style={[styles.overlayBadgePremium, styles.overlayBadgeNo]}>
<Text style={[styles.overlayTextPremium, styles.overlayTextNo]}>NO</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, styles.overlayMaybe, overlayStyle("up")]} pointerEvents="none">
<View style={[styles.overlayBadgePremium, styles.overlayBadgeMaybe]}>
<Text style={[styles.overlayTextPremium, styles.overlayTextMaybe]}>MAYBE</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, styles.overlaySkip, overlayStyle("down")]} pointerEvents="none">
<View style={[styles.overlayBadgePremium, styles.overlayBadgeSkip]}>
<Text style={[styles.overlayTextPremium, styles.overlayTextSkip]}>SKIP</Text>
</View>
</Animated.View>
{/* Content */}
<View style={styles.content}>
{/* Intensity badge - more prominent */}
<View style={styles.intensityBadge}>
<Text style={styles.intensityLabel}>INTENSITY</Text>
<View style={styles.intensityRow}>
{[...Array(5)].map((_, i) => (
<Ionicons
key={i}
name={i < question.intensity ? "flame" : "flame-outline"}
size={18}
color={i < question.intensity ? ACCENT : 'rgba(255, 255, 255, 0.2)'}
/>
))}
</View>
</View>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
{/* Question text */}
<Text style={styles.questionText}>
{question.text}
</Text>
{/* Partner's version of the question - only show when user is answering first */}
{question.partner_text && !question.partner_answered && (
<View style={styles.partnerTextContainer}>
<View style={styles.partnerDivider}>
<View style={styles.partnerDividerLine} />
<View style={styles.partnerLabelBadge}>
<Ionicons name="swap-horizontal" size={10} color={ROSE} />
<Text style={styles.partnerLabelText}>PARTNER SEES</Text>
</View>
<View style={styles.partnerDividerLine} />
</View>
<Text style={styles.partnerQuestionText}>{question.partner_text}</Text>
</View>
)}
{/* Bottom separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
</View>
{/* Premium Action Buttons */}
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<PremiumButton
onPress={() => handleButtonPress("left")}
icon="close"
variant="no"
/>
<PremiumButton
onPress={() => handleButtonPress("up")}
icon="help"
variant="maybe"
small
/>
<PremiumButton
onPress={() => handleButtonPress("right")}
icon="heart"
variant="yes"
/>
</View>
</View>
</View>
{/* Premium border */}
<View style={styles.premiumBorder} pointerEvents="none" />
</Animated.View>
</GestureDetector>
</View>
);
}
function PremiumButton({
onPress,
icon,
variant,
small = false,
}: {
onPress: () => void;
icon: string;
variant: 'yes' | 'no' | 'maybe';
small?: boolean;
}) {
const buttonScale = useSharedValue(1);
const handlePressIn = () => {
buttonScale.value = withSpring(0.92, animations.spring);
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
}));
const getColors = () => {
switch (variant) {
case 'yes': return { bg: rgba.gold(0.15), border: rgba.gold(0.3), icon: ACCENT };
case 'no': return { bg: rgba.rose(0.15), border: rgba.rose(0.3), icon: ROSE };
case 'maybe': return { bg: 'rgba(255, 255, 255, 0.08)', border: 'rgba(255, 255, 255, 0.15)', icon: colors.textSecondary };
}
};
const c = getColors();
return (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
>
<Animated.View style={[
styles.premiumButton,
small && styles.premiumButtonSmall,
{ backgroundColor: c.bg, borderColor: c.border },
buttonAnimatedStyle,
]}>
<Ionicons
name={icon as any}
size={small ? 18 : 24}
color={c.icon}
/>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
position: "absolute",
maxWidth: MAX_CARD_WIDTH,
height: 500,
},
cardOuter: {
width: "100%",
height: "100%",
borderRadius: radius.xl,
overflow: "hidden",
},
cardBackground: {
flex: 1,
backgroundColor: '#0d0d1a',
borderRadius: radius.xl,
overflow: "hidden",
},
silkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 120,
},
reportButton: {
position: 'absolute',
top: spacing.md,
right: spacing.md,
zIndex: 100,
padding: spacing.sm,
opacity: 0.6,
},
premiumBorder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.xl,
},
intensityBadge: {
alignItems: 'center',
backgroundColor: 'rgba(212, 175, 55, 0.08)', // gold @ 0.08
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)', // gold @ 0.15
marginBottom: spacing.md,
},
intensityLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: 'rgba(212, 175, 55, 0.7)', // gold @ 0.7
marginBottom: spacing.xs,
},
intensityRow: {
flexDirection: 'row',
gap: 4,
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.lg,
width: 120,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: ACCENT,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.5,
},
questionText: {
...typography.title2,
color: colors.text,
textAlign: "center",
lineHeight: 32,
paddingHorizontal: spacing.sm,
},
partnerTextContainer: {
marginTop: spacing.lg,
width: '100%',
paddingHorizontal: spacing.sm,
},
partnerDivider: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
partnerDividerLine: {
flex: 1,
height: 1,
backgroundColor: rgba.rose(0.2),
},
partnerLabelBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
backgroundColor: rgba.rose(0.1),
borderRadius: radius.full,
borderWidth: 1,
borderColor: rgba.rose(0.2),
gap: spacing.xs,
},
partnerLabelText: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 1.5,
color: rgba.rose(0.8),
},
partnerQuestionText: {
...typography.subhead,
color: colors.textSecondary,
textAlign: 'center',
fontStyle: 'italic',
opacity: 0.85,
},
footer: {
paddingBottom: spacing.xl,
paddingHorizontal: spacing.lg,
},
buttonContainer: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: spacing.lg,
},
premiumButton: {
width: 60,
height: 60,
borderRadius: 30,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
},
premiumButtonSmall: {
width: 48,
height: 48,
borderRadius: 24,
},
// Overlays
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderRadius: radius.xl,
},
overlayYes: {
backgroundColor: 'rgba(212, 175, 55, 0.15)', // gold @ 0.15
},
overlayNo: {
backgroundColor: 'rgba(232, 164, 174, 0.15)', // rose @ 0.15
},
overlayMaybe: {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
overlaySkip: {
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
overlayBadgePremium: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.full,
backgroundColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.4)', // gold @ 0.4
transform: [{ rotate: "-12deg" }],
},
overlayBadgeNo: {
backgroundColor: 'rgba(232, 164, 174, 0.2)', // rose @ 0.2
borderColor: 'rgba(232, 164, 174, 0.4)', // rose @ 0.4
},
overlayBadgeMaybe: {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255, 255, 255, 0.2)',
},
overlayBadgeSkip: {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255, 255, 255, 0.15)',
},
overlayTextPremium: {
fontSize: 28,
fontWeight: "600",
letterSpacing: 4,
color: ACCENT,
},
overlayTextNo: {
color: ROSE,
},
overlayTextMaybe: {
color: colors.textSecondary,
},
overlayTextSkip: {
color: colors.textTertiary,
fontSize: 24,
},
});
@@ -1,2 +0,0 @@
export { default as SwipeCard } from './SwipeCard';
export { default as SwipeCardPremium } from './SwipeCardPremium';
@@ -34,7 +34,7 @@ const STEPS: TutorialStep[] = [
{ {
key: "match", key: "match",
title: "You've Got a Match!", title: "You've Got a Match!",
description: "When both you and your partner swipe YES or MAYBE on the same question, it creates a match. This means you're both interested!", description: "When both you and your partner swipe YES on the same question, it creates a match. This means you're both interested!",
icon: "heart", icon: "heart",
gradient: gradients.primary as [string, string], gradient: gradients.primary as [string, string],
}, },
@@ -185,11 +185,6 @@ export default function MatchesTutorial({ onComplete }: Props) {
<Ionicons name="heart" size={16} color={colors.text} /> <Ionicons name="heart" size={16} color={colors.text} />
<Text style={styles.badgeText}>YES + YES</Text> <Text style={styles.badgeText}>YES + YES</Text>
</LinearGradient> </LinearGradient>
<View style={styles.badgeConnector} />
<View style={[styles.matchBadge, styles.maybeBadge]}>
<Ionicons name="heart-half" size={16} color={colors.primary} />
<Text style={[styles.badgeText, styles.maybeBadgeText]}>YES + MAYBE</Text>
</View>
</View> </View>
</View> </View>
)} )}
@@ -249,9 +244,9 @@ export default function MatchesTutorial({ onComplete }: Props) {
<Text style={styles.answerCardQuestion} numberOfLines={1}> <Text style={styles.answerCardQuestion} numberOfLines={1}>
"Try something new together" "Try something new together"
</Text> </Text>
<View style={[styles.answerCardBadge, styles.answerCardBadgeMaybe]}> <View style={[styles.answerCardBadge, styles.answerCardBadgeNo]}>
<Ionicons name="help" size={12} color={colors.warning} /> <Ionicons name="close" size={12} color={colors.error} />
<Text style={[styles.answerCardBadgeText, styles.answerCardBadgeTextMaybe]}>MAYBE</Text> <Text style={[styles.answerCardBadgeText, styles.answerCardBadgeTextNo]}>NO</Text>
</View> </View>
</View> </View>
</View> </View>
@@ -384,25 +379,12 @@ const styles = StyleSheet.create({
borderRadius: radius.full, borderRadius: radius.full,
gap: spacing.xs, gap: spacing.xs,
}, },
maybeBadge: {
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
},
badgeText: { badgeText: {
...typography.caption1, ...typography.caption1,
color: colors.text, color: colors.text,
fontWeight: "700", fontWeight: "700",
letterSpacing: 0.5, letterSpacing: 0.5,
}, },
maybeBadgeText: {
color: colors.primary,
},
badgeConnector: {
width: 20,
height: 2,
backgroundColor: colors.glass.border,
},
chatDemo: { chatDemo: {
marginTop: spacing.lg, marginTop: spacing.lg,
gap: spacing.sm, gap: spacing.sm,
@@ -508,18 +490,18 @@ const styles = StyleSheet.create({
borderRadius: radius.full, borderRadius: radius.full,
gap: 2, gap: 2,
}, },
answerCardBadgeMaybe: { answerCardBadgeNo: {
backgroundColor: "rgba(243, 156, 18, 0.2)", backgroundColor: "rgba(231, 76, 60, 0.2)",
borderWidth: 1, borderWidth: 1,
borderColor: colors.warning, borderColor: colors.error,
}, },
answerCardBadgeText: { answerCardBadgeText: {
...typography.caption2, ...typography.caption2,
fontWeight: "700", fontWeight: "700",
color: colors.text, color: colors.text,
}, },
answerCardBadgeTextMaybe: { answerCardBadgeTextNo: {
color: colors.warning, color: colors.error,
}, },
answerCardEdit: { answerCardEdit: {
marginLeft: spacing.sm, marginLeft: spacing.sm,
@@ -1,705 +0,0 @@
import { useEffect, useState } from "react";
import { View, Text, StyleSheet, Pressable, useWindowDimensions, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import { Ionicons } from "@expo/vector-icons";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withRepeat,
withSequence,
withDelay,
withSpring,
FadeIn,
FadeOut,
interpolate,
Easing,
runOnJS,
} from "react-native-reanimated";
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../../theme";
import { Events } from "../../lib/analytics";
interface Props {
onComplete: () => void;
}
interface TutorialStep {
key: string;
label: string;
description: string;
icon: string;
color: string;
gradient: [string, string];
translateX: number;
translateY: number;
isSwipe: boolean;
}
const STEPS: TutorialStep[] = [
{
key: "right",
label: "YES",
description: "Swipe right if you're interested",
icon: "heart",
color: colors.success,
gradient: gradients.success as [string, string],
translateX: 80,
translateY: 0,
isSwipe: true,
},
{
key: "left",
label: "NO",
description: "Swipe left if you're not interested",
icon: "close",
color: colors.error,
gradient: gradients.error as [string, string],
translateX: -80,
translateY: 0,
isSwipe: true,
},
{
key: "up",
label: "MAYBE",
description: "Swipe up if you're unsure",
icon: "help",
color: colors.warning,
gradient: gradients.warning as [string, string],
translateX: 0,
translateY: -80,
isSwipe: true,
},
{
key: "down",
label: "SKIP",
description: "Swipe down to skip for now",
icon: "arrow-down",
color: colors.textTertiary,
gradient: ["#6c757d", "#495057"],
translateX: 0,
translateY: 80,
isSwipe: true,
},
{
key: "feedback",
label: "FEEDBACK",
description: "Tap the flag to report issues with a question",
icon: "flag-outline",
color: colors.primary,
gradient: gradients.primary as [string, string],
translateX: 0,
translateY: 0,
isSwipe: false,
},
];
export default function SwipeTutorial({ onComplete }: Props) {
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
const [currentStep, setCurrentStep] = useState(0);
const cardTranslateX = useSharedValue(0);
const cardTranslateY = useSharedValue(0);
const cardRotation = useSharedValue(0);
const cardScale = useSharedValue(1);
const overlayOpacity = useSharedValue(0);
const pulseScale = useSharedValue(1);
const handOffset = useSharedValue(0);
const currentStep_ = STEPS[currentStep];
const isSwipeStep = currentStep_.isSwipe;
// Track tutorial started on mount
useEffect(() => {
Events.tutorialStarted("swipe");
Events.tutorialStepViewed("swipe", 0, STEPS[0].key);
}, []);
// Track step changes
useEffect(() => {
if (currentStep > 0) {
Events.tutorialStepViewed("swipe", currentStep, STEPS[currentStep].key);
}
}, [currentStep]);
useEffect(() => {
// Reset animations for current step
cardTranslateX.value = 0;
cardTranslateY.value = 0;
cardRotation.value = 0;
overlayOpacity.value = 0;
handOffset.value = 0;
// Start the swipe demonstration animation
const animateSwipe = () => {
if (!isSwipeStep) {
// For non-swipe steps (feedback), just pulse the card
cardScale.value = withRepeat(
withSequence(
withTiming(1.02, { duration: 600 }),
withTiming(1, { duration: 600 })
),
-1,
true
);
return;
}
// Pulse the card first
cardScale.value = withSequence(
withTiming(1.02, { duration: 200 }),
withTiming(1, { duration: 200 })
);
// Animate hand movement
handOffset.value = withRepeat(
withSequence(
withTiming(1, { duration: 800, easing: Easing.out(Easing.cubic) }),
withDelay(300, withTiming(0, { duration: 400 }))
),
-1,
false
);
// Animate card swipe
cardTranslateX.value = withRepeat(
withSequence(
withDelay(200, withTiming(currentStep_.translateX * 0.7, {
duration: 600,
easing: Easing.out(Easing.cubic)
})),
withDelay(400, withSpring(0, animations.spring))
),
-1,
false
);
cardTranslateY.value = withRepeat(
withSequence(
withDelay(200, withTiming(currentStep_.translateY * 0.7, {
duration: 600,
easing: Easing.out(Easing.cubic)
})),
withDelay(400, withSpring(0, animations.spring))
),
-1,
false
);
// Rotate card for horizontal swipes
if (currentStep_.translateX !== 0) {
cardRotation.value = withRepeat(
withSequence(
withDelay(200, withTiming(currentStep_.translateX > 0 ? 8 : -8, {
duration: 600
})),
withDelay(400, withSpring(0, animations.spring))
),
-1,
false
);
}
// Show overlay during swipe
overlayOpacity.value = withRepeat(
withSequence(
withDelay(400, withTiming(0.8, { duration: 400 })),
withDelay(200, withTiming(0, { duration: 400 }))
),
-1,
false
);
};
// Pulse animation for the indicator
pulseScale.value = withRepeat(
withSequence(
withTiming(1.2, { duration: 800 }),
withTiming(1, { duration: 800 })
),
-1,
true
);
const timeout = setTimeout(animateSwipe, 300);
return () => clearTimeout(timeout);
}, [currentStep]);
const handleNext = () => {
if (currentStep < STEPS.length - 1) {
setCurrentStep(currentStep + 1);
} else {
Events.tutorialCompleted("swipe");
onComplete();
}
};
const handleSkip = () => {
Events.tutorialSkipped("swipe", currentStep);
onComplete();
};
const cardAnimatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: cardTranslateX.value },
{ translateY: cardTranslateY.value },
{ rotate: `${cardRotation.value}deg` },
{ scale: cardScale.value },
],
}));
const overlayAnimatedStyle = useAnimatedStyle(() => ({
opacity: overlayOpacity.value,
}));
const pulseAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: pulseScale.value }],
opacity: interpolate(pulseScale.value, [1, 1.2], [0.6, 0.2]),
}));
const handAnimatedStyle = useAnimatedStyle(() => {
const progress = handOffset.value;
return {
transform: [
{ translateX: currentStep_.translateX * progress * 0.5 },
{ translateY: currentStep_.translateY * progress * 0.5 },
],
opacity: interpolate(progress, [0, 0.2, 0.8, 1], [0, 1, 1, 0]),
};
});
return (
<Animated.View
entering={FadeIn.duration(300)}
exiting={FadeOut.duration(200)}
style={styles.container}
>
<BlurView intensity={blur.heavy} tint="dark" style={styles.blur}>
<View style={[styles.content, { paddingBottom: Math.max(40, insets.bottom + (Platform.OS === 'ios' ? 100 : 80)) }]}>
{/* Skip button */}
<Pressable style={styles.skipButton} onPress={handleSkip}>
<Text style={styles.skipText}>Skip</Text>
</Pressable>
{/* Title */}
<Animated.View entering={FadeIn.delay(100).duration(400)}>
<Text style={styles.title}>How to Play</Text>
<Text style={styles.subtitle}>
Swipe to answer questions and find your matches
</Text>
</Animated.View>
{/* Demo Card Area */}
<View style={styles.demoArea}>
{/* Direction indicators around the card - only for swipe steps */}
{isSwipeStep && (
<View style={styles.indicatorsContainer}>
{STEPS.filter(s => s.isSwipe).map((dir, index) => (
<View
key={dir.key}
style={[
styles.directionIndicator,
dir.key === "right" && styles.indicatorRight,
dir.key === "left" && styles.indicatorLeft,
dir.key === "up" && styles.indicatorUp,
dir.key === "down" && styles.indicatorDown,
]}
>
{index === currentStep && (
<Animated.View style={[styles.pulseRing, pulseAnimatedStyle]}>
<View style={[styles.pulseInner, { backgroundColor: dir.color }]} />
</Animated.View>
)}
<LinearGradient
colors={index === currentStep ? dir.gradient : ['rgba(255,255,255,0.1)', 'rgba(255,255,255,0.05)'] as [string, string]}
style={[
styles.indicatorBadge,
index === currentStep && styles.indicatorActive,
]}
>
<Ionicons
name={dir.icon as any}
size={20}
color={index === currentStep ? colors.text : colors.textTertiary}
/>
</LinearGradient>
</View>
))}
</View>
)}
{/* Demo Card */}
<Animated.View style={[styles.demoCard, cardAnimatedStyle, shadows.xl]}>
<LinearGradient
colors={[colors.glass.backgroundLight, colors.glass.background]}
style={styles.demoCardInner}
>
{/* Feedback button indicator */}
<View style={[
styles.feedbackButtonDemo,
!isSwipeStep && styles.feedbackButtonHighlight
]}>
{!isSwipeStep && (
<Animated.View style={[styles.feedbackPulse, pulseAnimatedStyle]}>
<View style={[styles.pulseInner, { backgroundColor: currentStep_.color }]} />
</Animated.View>
)}
<Ionicons name="flag-outline" size={18} color={!isSwipeStep ? colors.text : colors.textTertiary} />
</View>
{/* Overlay - only for swipe steps */}
{isSwipeStep && (
<Animated.View
style={[
styles.demoOverlay,
overlayAnimatedStyle,
{ backgroundColor: `${currentStep_.color}40` }
]}
>
<View style={[styles.overlayBadge, { backgroundColor: currentStep_.color }]}>
<Text style={styles.overlayText}>{currentStep_.label}</Text>
</View>
</Animated.View>
)}
{/* Card content */}
<View style={styles.demoCardContent}>
<View style={styles.intensityDemo}>
<Ionicons name="flame" size={14} color={colors.primary} />
<Ionicons name="flame" size={14} color={colors.primary} />
</View>
<Text style={styles.demoQuestion}>
Would you try a new restaurant together?
</Text>
</View>
</LinearGradient>
{/* Hand gesture indicator - only for swipe steps */}
{isSwipeStep && (
<Animated.View style={[styles.handIndicator, handAnimatedStyle]}>
<View style={styles.handCircle}>
<Ionicons name="hand-left" size={24} color={colors.text} />
</View>
</Animated.View>
)}
</Animated.View>
</View>
{/* Current instruction */}
<Animated.View
key={currentStep}
entering={FadeIn.duration(300)}
style={styles.instructionContainer}
>
<LinearGradient
colors={currentStep_.gradient}
style={styles.instructionBadge}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Ionicons name={currentStep_.icon as any} size={24} color={colors.text} />
<Text style={styles.instructionLabel}>{currentStep_.label}</Text>
</LinearGradient>
<Text style={styles.instructionText}>{currentStep_.description}</Text>
</Animated.View>
{/* Progress dots */}
<View style={styles.dotsContainer}>
{STEPS.map((_, index) => (
<View
key={index}
style={[
styles.dot,
index === currentStep && styles.dotActive,
index < currentStep && styles.dotCompleted,
]}
/>
))}
</View>
{/* Next button */}
<Pressable onPress={handleNext} style={({ pressed }) => [
styles.nextButton,
pressed && styles.nextButtonPressed
]}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.nextButtonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Text style={styles.nextButtonText}>
{currentStep < STEPS.length - 1 ? "Next" : "Got it!"}
</Text>
<Ionicons
name={currentStep < STEPS.length - 1 ? "arrow-forward" : "checkmark"}
size={20}
color={colors.text}
/>
</LinearGradient>
</Pressable>
</View>
</BlurView>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
zIndex: 1000,
},
blur: {
flex: 1,
backgroundColor: 'rgba(13, 13, 26, 0.85)',
},
content: {
flex: 1,
paddingHorizontal: spacing.lg,
paddingTop: 80,
alignItems: "center",
},
skipButton: {
position: "absolute",
top: 60,
right: spacing.lg,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
},
skipText: {
...typography.callout,
color: colors.textSecondary,
},
title: {
...typography.title1,
color: colors.text,
textAlign: "center",
marginBottom: spacing.sm,
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
marginBottom: spacing.xl,
},
demoArea: {
flex: 1,
justifyContent: "center",
alignItems: "center",
width: "100%",
maxHeight: 350,
},
indicatorsContainer: {
position: "absolute",
width: 280,
height: 280,
},
directionIndicator: {
position: "absolute",
alignItems: "center",
justifyContent: "center",
},
indicatorRight: {
right: -20,
top: "50%",
marginTop: -24,
},
indicatorLeft: {
left: -20,
top: "50%",
marginTop: -24,
},
indicatorUp: {
top: -20,
left: "50%",
marginLeft: -24,
},
indicatorDown: {
bottom: -20,
left: "50%",
marginLeft: -24,
},
pulseRing: {
position: "absolute",
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
},
pulseInner: {
width: "100%",
height: "100%",
borderRadius: 32,
},
indicatorBadge: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
borderColor: colors.glass.border,
},
indicatorActive: {
borderColor: "transparent",
},
demoCard: {
width: 220,
height: 280,
borderRadius: radius.xl,
overflow: "visible",
},
demoCardInner: {
flex: 1,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: colors.glass.border,
overflow: "hidden",
},
feedbackButtonDemo: {
position: 'absolute',
top: spacing.md,
right: spacing.md,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.glass.background,
justifyContent: 'center',
alignItems: 'center',
zIndex: 100,
borderWidth: 1,
borderColor: colors.glass.border,
},
feedbackButtonHighlight: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
feedbackPulse: {
position: "absolute",
width: 56,
height: 56,
borderRadius: 28,
justifyContent: "center",
alignItems: "center",
},
demoOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderRadius: radius.xl,
},
overlayBadge: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
transform: [{ rotate: "-15deg" }],
},
overlayText: {
fontSize: 24,
fontWeight: "900",
color: colors.text,
letterSpacing: 2,
},
demoCardContent: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
},
intensityDemo: {
flexDirection: "row",
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
gap: 2,
marginBottom: spacing.md,
},
demoQuestion: {
...typography.headline,
color: colors.text,
textAlign: "center",
},
handIndicator: {
position: "absolute",
bottom: 40,
left: "50%",
marginLeft: -20,
},
handCircle: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primary,
justifyContent: "center",
alignItems: "center",
...shadows.md,
},
instructionContainer: {
alignItems: "center",
marginTop: spacing.xl,
marginBottom: spacing.lg,
},
instructionBadge: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.sm,
marginBottom: spacing.md,
},
instructionLabel: {
...typography.headline,
color: colors.text,
fontWeight: "700",
},
instructionText: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
},
dotsContainer: {
flexDirection: "row",
gap: spacing.sm,
marginBottom: spacing.xl,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.glass.border,
},
dotActive: {
backgroundColor: colors.primary,
width: 24,
},
dotCompleted: {
backgroundColor: colors.primary,
},
nextButton: {
width: "100%",
maxWidth: 280,
borderRadius: radius.lg,
overflow: "hidden",
},
nextButtonPressed: {
opacity: 0.9,
transform: [{ scale: 0.98 }],
},
nextButtonGradient: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: spacing.md,
gap: spacing.sm,
},
nextButtonText: {
...typography.headline,
color: colors.text,
},
});
@@ -1,2 +1 @@
export { default as SwipeTutorial } from './SwipeTutorial';
export { default as MatchesTutorial } from './MatchesTutorial'; export { default as MatchesTutorial } from './MatchesTutorial';
@@ -2,10 +2,10 @@ import React from 'react';
import { View, StyleSheet, ViewStyle, DimensionValue } from 'react-native'; import { View, StyleSheet, ViewStyle, DimensionValue } from 'react-native';
import { colors, spacing } from '../../theme'; import { colors, spacing } from '../../theme';
export type SeparatorVariant = 'rose' | 'gold' | 'muted'; export type SeparatorVariant = 'primary' | 'rose' | 'gold' | 'muted';
export interface DecorativeSeparatorProps { export interface DecorativeSeparatorProps {
/** Color variant: 'rose' (default), 'gold' (premium/yes-yes), 'muted' */ /** Color variant: 'primary' (default), 'rose' (quiz), 'gold' (premium/dares), 'muted' */
variant?: SeparatorVariant; variant?: SeparatorVariant;
/** Width of the separator. Default: 140 */ /** Width of the separator. Default: 140 */
width?: DimensionValue; width?: DimensionValue;
@@ -16,6 +16,10 @@ export interface DecorativeSeparatorProps {
} }
const VARIANT_COLORS: Record<SeparatorVariant, { line: string; diamond: string }> = { const VARIANT_COLORS: Record<SeparatorVariant, { line: string; diamond: string }> = {
primary: {
line: 'rgba(225, 48, 108, 0.3)',
diamond: colors.primary,
},
rose: { rose: {
line: 'rgba(232, 164, 174, 0.3)', line: 'rgba(232, 164, 174, 0.3)',
diamond: colors.premium.rose, diamond: colors.premium.rose,
@@ -35,7 +39,7 @@ const VARIANT_COLORS: Record<SeparatorVariant, { line: string; diamond: string }
* Used for visual separation in waiting states, headers, and empty states. * Used for visual separation in waiting states, headers, and empty states.
*/ */
export const DecorativeSeparator: React.FC<DecorativeSeparatorProps> = ({ export const DecorativeSeparator: React.FC<DecorativeSeparatorProps> = ({
variant = 'rose', variant = 'primary',
width = 140, width = 140,
marginVertical = spacing.lg, marginVertical = spacing.lg,
style, style,
+7 -53
View File
@@ -1,13 +1,11 @@
import React from 'react'; import React from 'react';
import { StyleSheet, View, ViewStyle, Platform } from 'react-native'; import { StyleSheet, View, ViewStyle } from 'react-native';
import { BlurView } from 'expo-blur'; import { colors, radius, shadows } from '../../theme';
import { LinearGradient } from 'expo-linear-gradient';
import { colors, radius, shadows, blur } from '../../theme';
interface GlassCardProps { interface GlassCardProps {
children: React.ReactNode; children: React.ReactNode;
style?: ViewStyle; style?: ViewStyle;
intensity?: 'light' | 'medium' | 'heavy'; intensity?: 'light' | 'medium' | 'heavy'; // Kept for API compatibility
variant?: 'default' | 'elevated' | 'subtle'; variant?: 'default' | 'elevated' | 'subtle';
noPadding?: boolean; noPadding?: boolean;
} }
@@ -15,50 +13,19 @@ interface GlassCardProps {
export function GlassCard({ export function GlassCard({
children, children,
style, style,
intensity = 'medium',
variant = 'default', variant = 'default',
noPadding = false, noPadding = false,
}: GlassCardProps) { }: GlassCardProps) {
const blurIntensity = blur[intensity];
const shadowStyle = variant === 'elevated' ? shadows.lg : variant === 'subtle' ? shadows.sm : shadows.md; const shadowStyle = variant === 'elevated' ? shadows.lg : variant === 'subtle' ? shadows.sm : shadows.md;
const borderColor = variant === 'elevated' ? colors.glass.borderLight : colors.glass.border; const borderColor = variant === 'elevated' ? colors.glass.borderLight : colors.glass.border;
// Use BlurView on iOS, fallback to solid background on Android for performance
const useBlur = Platform.OS === 'ios';
return ( return (
<View style={[styles.container, shadowStyle, style]}> <View style={[styles.container, shadowStyle, style]}>
{useBlur ? ( <View style={[styles.flatContainer, { borderColor }]}>
<BlurView
intensity={blurIntensity}
tint="dark"
style={[styles.blurContainer, { borderColor }]}
>
{/* Top highlight gradient */}
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={[styles.content, noPadding && styles.noPadding]}>
{children}
</View>
</BlurView>
) : (
// Android fallback with solid background
<View style={[styles.fallbackContainer, { borderColor }]}>
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={[styles.content, noPadding && styles.noPadding]}> <View style={[styles.content, noPadding && styles.noPadding]}>
{children} {children}
</View> </View>
</View> </View>
)}
</View> </View>
); );
} }
@@ -68,24 +35,11 @@ const styles = StyleSheet.create({
borderRadius: radius.lg, borderRadius: radius.lg,
overflow: 'hidden', overflow: 'hidden',
}, },
blurContainer: { flatContainer: {
overflow: 'hidden', overflow: 'hidden',
borderRadius: radius.lg, borderRadius: radius.lg,
borderWidth: 1, backgroundColor: colors.backgroundLight, // Use solid card color
backgroundColor: colors.glass.background, // Removed border for flatter look
},
fallbackContainer: {
overflow: 'hidden',
borderRadius: radius.lg,
borderWidth: 1,
backgroundColor: colors.glass.backgroundLight,
},
highlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
}, },
content: { content: {
padding: 16, padding: 16,
+14 -37
View File
@@ -1,13 +1,12 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { StyleSheet, TextInput, View, Text, TextInputProps, ViewStyle, Platform } from 'react-native'; import { StyleSheet, TextInput, View, Text, TextInputProps, ViewStyle, Platform } from 'react-native';
import { BlurView } from 'expo-blur';
import Animated, { import Animated, {
useSharedValue, useSharedValue,
useAnimatedStyle, useAnimatedStyle,
withTiming, withTiming,
interpolateColor, interpolateColor,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { colors, radius, blur, animations, typography } from '../../theme'; import { colors, radius, animations, typography } from '../../theme';
interface GlassInputProps extends Omit<TextInputProps, 'style'> { interface GlassInputProps extends Omit<TextInputProps, 'style'> {
label?: string; label?: string;
@@ -16,7 +15,6 @@ interface GlassInputProps extends Omit<TextInputProps, 'style'> {
icon?: React.ReactNode; icon?: React.ReactNode;
} }
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
const AnimatedView = Animated.View; const AnimatedView = Animated.View;
export function GlassInput({ export function GlassInput({
@@ -41,17 +39,24 @@ export function GlassInput({
props.onBlur?.(e); props.onBlur?.(e);
}; };
const animatedBorderStyle = useAnimatedStyle(() => ({ const animatedContainerStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
focusAnim.value,
[0, 1],
[colors.backgroundLight, colors.backgroundLight] // Consistent flat background
),
borderColor: interpolateColor( borderColor: interpolateColor(
focusAnim.value, focusAnim.value,
[0, 1], [0, 1],
[error ? colors.error : colors.glass.border, error ? colors.error : colors.primary] [error ? colors.error : colors.border, error ? colors.error : colors.primary]
), ),
})); }));
const useBlur = Platform.OS === 'ios'; return (
<View style={[styles.container, containerStyle]}>
{label && <Text style={styles.label}>{label}</Text>}
const inputContent = ( <AnimatedView style={[styles.inputContainer, animatedContainerStyle]}>
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
{icon && <View style={styles.icon}>{icon}</View>} {icon && <View style={styles.icon}>{icon}</View>}
<TextInput <TextInput
@@ -63,27 +68,7 @@ export function GlassInput({
selectionColor={colors.primary} selectionColor={colors.primary}
/> />
</View> </View>
);
return (
<View style={[styles.container, containerStyle]}>
{label && <Text style={styles.label}>{label}</Text>}
{useBlur ? (
<AnimatedView style={[styles.blurWrapper, animatedBorderStyle]}>
<BlurView
intensity={blur.light}
tint="dark"
style={styles.blurContainer}
>
{inputContent}
</BlurView>
</AnimatedView> </AnimatedView>
) : (
<AnimatedView style={[styles.fallbackContainer, animatedBorderStyle]}>
{inputContent}
</AnimatedView>
)}
{error && <Text style={styles.error}>{error}</Text>} {error && <Text style={styles.error}>{error}</Text>}
</View> </View>
@@ -100,19 +85,11 @@ const styles = StyleSheet.create({
marginBottom: 8, marginBottom: 8,
marginLeft: 4, marginLeft: 4,
}, },
blurWrapper: { inputContainer: {
borderRadius: radius.md, borderRadius: radius.lg,
borderWidth: 1, borderWidth: 1,
overflow: 'hidden', overflow: 'hidden',
}, },
blurContainer: {
backgroundColor: colors.glass.background,
},
fallbackContainer: {
borderRadius: radius.md,
borderWidth: 1,
backgroundColor: colors.glass.backgroundLight,
},
inputWrapper: { inputWrapper: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -66,11 +66,18 @@ export function GlassToggle({
const backgroundColor = interpolateColor( const backgroundColor = interpolateColor(
progress.value, progress.value,
[0, 1], [0, 1],
['rgba(255, 255, 255, 0.1)', 'transparent'] // We use LinearGradient for active state [colors.backgroundLight, 'transparent'] // Use backgroundLight for inactive
);
const borderColor = interpolateColor(
progress.value,
[0, 1],
[colors.border, 'transparent'] // Use standard border for inactive
); );
return { return {
backgroundColor, backgroundColor,
borderColor,
}; };
}); });
@@ -114,7 +121,7 @@ const styles = StyleSheet.create({
height: '100%', height: '100%',
borderRadius: radius.full, borderRadius: radius.full,
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)', borderColor: colors.border, // Use standard border
overflow: 'hidden', overflow: 'hidden',
justifyContent: 'center', justifyContent: 'center',
padding: PADDING, padding: PADDING,
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Platform, Image, Modal, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, Pressable, Platform, Modal, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import { Image } from 'expo-image';
import Animated, { import Animated, {
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
@@ -126,6 +127,9 @@ export function IntensitySlider({
<Image <Image
source={{ uri: partnerAvatar }} source={{ uri: partnerAvatar }}
style={styles.partnerAvatarSmall} style={styles.partnerAvatarSmall}
cachePolicy="memory-disk"
contentFit="cover"
transition={150}
/> />
) : ( ) : (
<Ionicons name="heart" size={8} color={colors.secondary} /> <Ionicons name="heart" size={8} color={colors.secondary} />
@@ -189,7 +193,13 @@ export function IntensitySlider({
onPress={() => setShowInfoModal(true)} onPress={() => setShowInfoModal(true)}
> >
{partnerAvatar ? ( {partnerAvatar ? (
<Image source={{ uri: partnerAvatar }} style={styles.partnerAvatarTiny} /> <Image
source={{ uri: partnerAvatar }}
style={styles.partnerAvatarTiny}
cachePolicy="memory-disk"
contentFit="cover"
transition={150}
/>
) : ( ) : (
<View style={styles.partnerIconTiny}> <View style={styles.partnerIconTiny}>
<Ionicons name="person" size={10} color={colors.secondary} /> <Ionicons name="person" size={10} color={colors.secondary} />
@@ -276,7 +286,13 @@ export function IntensitySlider({
<View style={styles.comparisonPerson}> <View style={styles.comparisonPerson}>
{partnerAvatar ? ( {partnerAvatar ? (
<Image source={{ uri: partnerAvatar }} style={styles.comparisonAvatarImage} /> <Image
source={{ uri: partnerAvatar }}
style={styles.comparisonAvatarImage}
cachePolicy="memory-disk"
contentFit="cover"
transition={150}
/>
) : ( ) : (
<View style={[styles.comparisonAvatar, { backgroundColor: HEAT_COLORS[(partnerValue ?? 1) - 1] }]}> <View style={[styles.comparisonAvatar, { backgroundColor: HEAT_COLORS[(partnerValue ?? 1) - 1] }]}>
<Text style={styles.comparisonEmoji}>{partnerLevel?.emoji}</Text> <Text style={styles.comparisonEmoji}>{partnerLevel?.emoji}</Text>
@@ -388,13 +404,13 @@ const styles = StyleSheet.create({
borderRadius: 18, borderRadius: 18,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.05)', backgroundColor: colors.backgroundLight, // Flat
borderWidth: 2, borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.1)', borderColor: colors.border,
}, },
levelIndicatorActive: { levelIndicatorActive: {
backgroundColor: 'rgba(233, 69, 96, 0.15)', backgroundColor: colors.background, // Nested
borderColor: 'rgba(233, 69, 96, 0.3)', borderColor: colors.primary,
}, },
levelIndicatorPartner: { levelIndicatorPartner: {
borderColor: colors.secondary, borderColor: colors.secondary,
@@ -435,15 +451,15 @@ const styles = StyleSheet.create({
color: colors.secondary, color: colors.secondary,
}, },
currentCard: { currentCard: {
backgroundColor: 'rgba(255, 255, 255, 0.05)', backgroundColor: colors.backgroundLight, // Flat
borderRadius: radius.md, borderRadius: radius.md,
padding: spacing.md, padding: spacing.md,
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)', borderColor: colors.border,
}, },
currentCardWarning: { currentCardWarning: {
borderColor: 'rgba(243, 156, 18, 0.3)', borderColor: colors.warning,
backgroundColor: 'rgba(243, 156, 18, 0.05)', backgroundColor: colors.background, // Use background for warning state to differentiate
}, },
currentHeader: { currentHeader: {
flexDirection: 'row', flexDirection: 'row',
@@ -486,7 +502,7 @@ const styles = StyleSheet.create({
marginTop: spacing.sm, marginTop: spacing.sm,
paddingTop: spacing.sm, paddingTop: spacing.sm,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.08)', borderTopColor: colors.border,
}, },
partnerAvatarTiny: { partnerAvatarTiny: {
width: 20, width: 20,
@@ -509,7 +525,6 @@ const styles = StyleSheet.create({
mismatchTextWarning: { mismatchTextWarning: {
color: colors.warning, color: colors.warning,
}, },
// Modal styles
modalOverlay: { modalOverlay: {
flex: 1, flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.7)', backgroundColor: 'rgba(0, 0, 0, 0.7)',
@@ -522,13 +537,13 @@ const styles = StyleSheet.create({
maxWidth: 360, maxWidth: 360,
borderRadius: radius.xl, borderRadius: radius.xl,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.backgroundLight, backgroundColor: colors.backgroundLight, // Flat
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)', borderColor: colors.border,
}, },
modalInner: { modalInner: {
padding: spacing.lg, padding: spacing.lg,
backgroundColor: Platform.OS === 'ios' ? 'rgba(26, 26, 46, 0.85)' : 'transparent', backgroundColor: colors.backgroundLight,
}, },
modalHeader: { modalHeader: {
alignItems: 'center', alignItems: 'center',
@@ -551,7 +566,7 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.05)', backgroundColor: colors.background,
borderRadius: radius.lg, borderRadius: radius.lg,
padding: spacing.md, padding: spacing.md,
marginBottom: spacing.lg, marginBottom: spacing.lg,
@@ -627,12 +642,12 @@ const styles = StyleSheet.create({
color: colors.warning, color: colors.warning,
}, },
modalCloseButton: { modalCloseButton: {
backgroundColor: colors.glass.background, backgroundColor: colors.background,
borderRadius: radius.md, borderRadius: radius.md,
paddingVertical: spacing.md, paddingVertical: spacing.md,
alignItems: 'center', alignItems: 'center',
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.border, borderColor: colors.border,
}, },
modalCloseText: { modalCloseText: {
...typography.subhead, ...typography.subhead,
+44
View File
@@ -0,0 +1,44 @@
/**
* Required onboarding version.
*
* Bump this number when you need existing users to go through onboarding again
* to collect new required data (e.g., profile pictures, new fields).
*
* Version history:
* - 1: Initial onboarding (avatar, name, gender, purpose, content prefs, notifications)
* - 2: Made avatar mandatory for all users
*/
export const REQUIRED_ONBOARDING_VERSION = 2;
/**
* RevenueCat offering identifier for the onboarding discount paywall.
*
* Create this offering in RevenueCat dashboard with discounted products.
* If the offering doesn't exist, the paywall will fall back to the default offering.
*
* To set up:
* 1. Create discounted products in App Store Connect (e.g., "sauci_annual_onboarding")
* 2. In RevenueCat → Products, add these products
* 3. In RevenueCat → Offerings, create offering "onboarding_promo" with the discounted products
*/
export const ONBOARDING_OFFERING_ID = "onboarding_promo";
/**
* Check if a user needs to complete onboarding based on their current version
*/
export function needsOnboarding(
onboardingCompleted: boolean | undefined,
onboardingVersion: number | undefined
): boolean {
// Never completed onboarding
if (!onboardingCompleted) {
return true;
}
// Completed an older version - need to re-onboard
if ((onboardingVersion ?? 0) < REQUIRED_ONBOARDING_VERSION) {
return true;
}
return false;
}
+11 -3
View File
@@ -308,7 +308,7 @@ export const ChatScreen: React.FC = () => {
return ( return (
<GradientBackground> <GradientBackground>
{/* Ambient Orbs */} {/* Ambient Orbs - Commented out for flat look
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none"> <Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
<LinearGradient <LinearGradient
colors={[colors.premium.goldGlow, 'transparent']} colors={[colors.premium.goldGlow, 'transparent']}
@@ -325,6 +325,7 @@ export const ChatScreen: React.FC = () => {
end={{ x: 0, y: 0 }} end={{ x: 0, y: 0 }}
/> />
</Animated.View> </Animated.View>
*/}
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.keyboardAvoiding} style={styles.keyboardAvoiding}
@@ -343,6 +344,8 @@ export const ChatScreen: React.FC = () => {
<ChatMessages <ChatMessages
messages={messages} messages={messages}
userId={user?.id} userId={user?.id}
userName={user?.name ?? undefined}
partnerName={partner?.name ?? undefined}
match={match} match={match}
uploadStatus={uploadStatus} uploadStatus={uploadStatus}
partnerTyping={partnerTyping} partnerTyping={partnerTyping}
@@ -357,15 +360,20 @@ export const ChatScreen: React.FC = () => {
entering={FadeInDown.duration(300)} entering={FadeInDown.duration(300)}
style={[styles.inputWrapper, { paddingBottom: insets.bottom || spacing.sm }]} style={[styles.inputWrapper, { paddingBottom: insets.bottom || spacing.sm }]}
> >
{/* Gradient background */} {/* Flat background */}
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.backgroundLight, borderTopWidth: 1, borderTopColor: colors.border }]} />
{/* Gradient background - removed
<LinearGradient <LinearGradient
colors={['rgba(22, 33, 62, 0.9)', 'rgba(13, 13, 26, 0.95)']} colors={['rgba(22, 33, 62, 0.9)', 'rgba(13, 13, 26, 0.95)']}
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }} end={{ x: 0, y: 1 }}
/> />
{/* Top border glow */} */}
{/* Top border glow - removed
<View style={styles.inputWrapperBorder} /> <View style={styles.inputWrapperBorder} />
*/}
<InputBar <InputBar
inputText={inputText} inputText={inputText}
@@ -31,20 +31,25 @@ const ChatHeaderComponent: React.FC<ChatHeaderProps> = ({
entering={FadeIn.duration(300)} entering={FadeIn.duration(300)}
style={[styles.header, { paddingTop: insets.top + spacing.sm }]} style={[styles.header, { paddingTop: insets.top + spacing.sm }]}
> >
{/* Gradient background */} {/* Flat background */}
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.background, borderBottomWidth: 1, borderBottomColor: colors.border }]} />
{/* Gradient background - removed
<LinearGradient <LinearGradient
colors={['rgba(22, 33, 62, 0.8)', 'rgba(13, 13, 26, 0.6)']} colors={['rgba(22, 33, 62, 0.8)', 'rgba(13, 13, 26, 0.6)']}
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }} end={{ x: 0, y: 1 }}
/> />
{/* Top silk highlight */} */}
{/* Top silk highlight - removed
<LinearGradient <LinearGradient
colors={[`${ACCENT_RGBA}0.1)`, 'transparent']} colors={[`${ACCENT_RGBA}0.1)`, 'transparent']}
style={styles.headerSilkHighlight} style={styles.headerSilkHighlight}
start={{ x: 0.5, y: 0 }} start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }} end={{ x: 0.5, y: 1 }}
/> />
*/}
<TouchableOpacity <TouchableOpacity
onPress={onBack} onPress={onBack}
@@ -138,12 +143,25 @@ const styles = StyleSheet.create({
width: 36, width: 36,
height: 36, height: 36,
borderRadius: 18, borderRadius: 18,
backgroundColor: `${ACCENT_RGBA}0.1)`, backgroundColor: colors.backgroundLight,
borderWidth: 1, borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`, borderColor: colors.border,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
// ...
headerSpacer: {
width: 40,
},
settingsButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
headerBorderBottom: {
display: 'none',
},
headerCenter: { headerCenter: {
flex: 1, flex: 1,
flexDirection: 'row', flexDirection: 'row',
@@ -195,21 +213,5 @@ const styles = StyleSheet.create({
...typography.headline, ...typography.headline,
color: colors.text, color: colors.text,
}, },
headerSpacer: {
width: 40,
},
settingsButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
headerBorderBottom: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 1,
backgroundColor: `${ACCENT_RGBA}0.15)`,
},
}); });
@@ -19,6 +19,8 @@ type Message = Database['public']['Tables']['messages']['Row'];
interface ChatMessagesProps { interface ChatMessagesProps {
messages: Message[]; messages: Message[];
userId: string | undefined; userId: string | undefined;
userName: string | undefined;
partnerName: string | undefined;
match: Match | null; match: Match | null;
uploadStatus: UploadStatus | null; uploadStatus: UploadStatus | null;
partnerTyping: boolean; partnerTyping: boolean;
@@ -46,6 +48,8 @@ function isSameDay(date1: Date, date2: Date): boolean {
const ChatMessagesComponent: React.FC<ChatMessagesProps> = ({ const ChatMessagesComponent: React.FC<ChatMessagesProps> = ({
messages, messages,
userId, userId,
userName,
partnerName,
match, match,
uploadStatus, uploadStatus,
partnerTyping, partnerTyping,
@@ -108,9 +112,9 @@ const ChatMessagesComponent: React.FC<ChatMessagesProps> = ({
// Memoize ListFooterComponent to prevent recreation // Memoize ListFooterComponent to prevent recreation
const listFooterComponent = useMemo(() => ( const listFooterComponent = useMemo(() => (
<View style={styles.footerContainer}> <View style={styles.footerContainer}>
<MatchCard match={match} user={userObject} /> <MatchCard match={match} user={userObject} userName={userName} partnerName={partnerName} onImagePress={onImagePress} />
</View> </View>
), [match, userObject]); ), [match, userObject, userName, partnerName, onImagePress]);
// Memoize ListEmptyComponent to prevent recreation // Memoize ListEmptyComponent to prevent recreation
const listEmptyComponent = useMemo(() => ( const listEmptyComponent = useMemo(() => (
@@ -203,45 +203,32 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
plusButton: { plusButton: {
backgroundColor: 'rgba(212, 175, 55, 0.1)', backgroundColor: colors.backgroundLight,
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)', borderColor: colors.border,
},
mediaMenuContainer: {
flexDirection: 'row',
overflow: 'hidden',
gap: spacing.xs,
},
mediaMenuItem: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
}, },
// ...
mediaMenuItemInner: { mediaMenuItemInner: {
width: 36, width: 36,
height: 36, height: 36,
borderRadius: 18, borderRadius: 18,
backgroundColor: 'rgba(255, 255, 255, 0.05)', backgroundColor: colors.backgroundLight,
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)', borderColor: colors.border,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
inputFieldWrapper: { // ...
flex: 1,
position: 'relative',
},
inputFieldBorder: { inputFieldBorder: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
borderRadius: radius.xl, borderRadius: radius.xl,
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)', borderColor: colors.border,
}, },
input: { input: {
...typography.body, ...typography.body,
color: colors.text, color: colors.text,
backgroundColor: 'rgba(22, 33, 62, 0.6)', backgroundColor: colors.background, // Nested flat background
borderRadius: radius.xl, borderRadius: radius.xl,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingTop: 12, paddingTop: 12,
@@ -255,7 +242,27 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
overflow: 'hidden', overflow: 'hidden',
// Keep gradient for primary action to make it pop
}, },
mediaMenuContainer: {
flexDirection: 'row',
overflow: 'hidden',
gap: spacing.xs,
},
mediaMenuItem: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
inputFieldWrapper: {
flex: 1,
position: 'relative',
},
sendButtonHighlight: { sendButtonHighlight: {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
@@ -1,12 +1,16 @@
import React, { useRef } from 'react'; import React, { useRef, useState, useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { Image } from 'expo-image';
import { Audio } from 'expo-av';
import Animated, { FadeInDown } from 'react-native-reanimated'; import Animated, { FadeInDown } from 'react-native-reanimated';
import { DecorativeSeparator } from '../../../components/ui'; import { DecorativeSeparator } from '../../../components/ui';
import { colors, gradients, spacing, radius, typography } from '../../../theme'; import { colors, gradients, spacing, radius, typography } from '../../../theme';
import { Match } from '../types'; import { Match } from '../types';
import { ResponseData } from '@sauci/shared';
import { getCachedSignedUrl, getStoragePath } from '../../../lib/imageCache';
// Premium color palette // Premium color palette
const ACCENT = colors.premium.gold; const ACCENT = colors.premium.gold;
@@ -15,9 +19,356 @@ const ACCENT_RGBA = 'rgba(212, 175, 55, ';
interface MatchCardProps { interface MatchCardProps {
match: Match | null; match: Match | null;
user: { id: string } | null; user: { id: string } | null;
userName?: string;
partnerName?: string;
onImagePress?: (uri: string) => void;
} }
const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => { /**
* Helper function to get display text for who_likely choices
*/
function getChoiceDisplay(chosenUserId: string, userId: string, userName: string, partnerName: string): string {
return chosenUserId === userId ? userName : partnerName;
}
/**
* Component to render text_answer response summary
*/
const TextAnswerDisplay: React.FC<{
responseSummary: Record<string, ResponseData>;
userId: string;
partnerName: string;
}> = ({ responseSummary, userId, partnerName }) => {
const entries = Object.entries(responseSummary);
return (
<View style={styles.responseSummaryContainer}>
{entries.map(([responderId, data]) => {
if (data.type !== 'text_answer') return null;
const isUser = responderId === userId;
const name = isUser ? 'You' : partnerName;
return (
<View key={responderId} style={styles.textResponseCard}>
<Text style={styles.textResponseName}>{name}</Text>
<View style={[
styles.textBubble,
isUser ? styles.textBubbleUser : styles.textBubblePartner
]}>
<Text style={styles.textBubbleText}>"{data.text}"</Text>
</View>
</View>
);
})}
</View>
);
};
/**
* Component to render photo response summary
*/
const PhotoDisplay: React.FC<{
responseSummary: Record<string, ResponseData>;
userId: string;
partnerName: string;
onImagePress?: (uri: string) => void;
}> = ({ responseSummary, userId, partnerName, onImagePress }) => {
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>({});
const [loadingUrls, setLoadingUrls] = useState(true);
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>({});
useEffect(() => {
const fetchPhotoUrls = async () => {
// Fetch all signed URLs in parallel for faster loading
const photoEntries: Array<[string, { type: 'photo'; media_path: string }]> = [];
for (const [responderId, data] of Object.entries(responseSummary)) {
if (data.type === 'photo') {
photoEntries.push([responderId, data]);
}
}
const urlPromises = photoEntries.map(async ([responderId, data]) => {
try {
const storagePath = getStoragePath(data.media_path);
const signedUrl = await getCachedSignedUrl(storagePath, 'response-media');
return { responderId, signedUrl };
} catch (err) {
console.error('Failed to get photo URL:', err);
return { responderId, signedUrl: null };
}
});
const results = await Promise.all(urlPromises);
const urls: Record<string, string> = {};
// Collect URLs and prefetch images
const prefetchPromises: Promise<boolean>[] = [];
for (const { responderId, signedUrl } of results) {
if (signedUrl) {
urls[responderId] = signedUrl;
// Prefetch image for faster display
prefetchPromises.push(Image.prefetch(signedUrl));
}
}
// Set URLs immediately, prefetch in background
setPhotoUrls(urls);
setLoadingUrls(false);
// Initialize loading state for each image
const initialLoading: Record<string, boolean> = {};
for (const responderId of Object.keys(urls)) {
initialLoading[responderId] = true;
}
setLoadingImages(initialLoading);
// Wait for prefetch to complete (non-blocking)
Promise.all(prefetchPromises).catch(() => {
// Prefetch failures are non-critical
});
};
fetchPhotoUrls();
}, [responseSummary]);
const handleImageLoad = (responderId: string) => {
setLoadingImages(prev => ({ ...prev, [responderId]: false }));
};
const entries = Object.entries(responseSummary);
return (
<View style={styles.photoContainer}>
{entries.map(([responderId, data]) => {
if (data.type !== 'photo') return null;
const isUser = responderId === userId;
const name = isUser ? 'You' : partnerName;
const photoUrl = photoUrls[responderId];
const isImageLoading = loadingImages[responderId];
return (
<View key={responderId} style={styles.photoCard}>
<Text style={styles.photoName}>{name}</Text>
<TouchableOpacity
style={styles.photoThumbnailContainer}
onPress={() => photoUrl && onImagePress?.(photoUrl)}
disabled={!photoUrl}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityLabel={`View ${name}'s photo full screen`}
>
{loadingUrls ? (
<View style={styles.photoPlaceholder}>
<ActivityIndicator size="small" color={colors.premium.gold} />
</View>
) : photoUrl ? (
<>
<Image
source={{ uri: photoUrl }}
style={styles.photoThumbnail}
contentFit="cover"
cachePolicy="disk"
transition={200}
onLoad={() => handleImageLoad(responderId)}
/>
{isImageLoading && (
<View style={styles.photoLoadingOverlay}>
<ActivityIndicator size="small" color={colors.premium.gold} />
</View>
)}
</>
) : (
<View style={styles.photoPlaceholder}>
<Ionicons name="image-outline" size={24} color={colors.textTertiary} />
</View>
)}
</TouchableOpacity>
</View>
);
})}
</View>
);
};
/**
* Component to render who_likely response summary
*/
const WhoLikelyDisplay: React.FC<{
responseSummary: Record<string, ResponseData>;
userId: string;
userName: string;
partnerName: string;
}> = ({ responseSummary, userId, userName, partnerName }) => {
const entries = Object.entries(responseSummary);
return (
<View style={styles.whoLikelyContainer}>
{entries.map(([responderId, data]) => {
if (data.type !== 'who_likely') return null;
const isUser = responderId === userId;
const voterName = isUser ? 'You' : partnerName;
const choiceName = getChoiceDisplay(data.chosen_user_id, userId, userName, partnerName);
return (
<View key={responderId} style={styles.whoLikelyCard}>
<Text style={styles.whoLikelyVoter}>{voterName} chose:</Text>
<LinearGradient
colors={gradients.premiumRose as [string, string]}
style={styles.whoLikelyChoiceBadge}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={styles.whoLikelyChoiceText}>{choiceName}</Text>
</LinearGradient>
</View>
);
})}
</View>
);
};
/**
* Component to render audio response summary
*/
const AudioDisplay: React.FC<{
responseSummary: Record<string, ResponseData>;
userId: string;
partnerName: string;
}> = ({ responseSummary, userId, partnerName }) => {
const [audioUrls, setAudioUrls] = useState<Record<string, string>>({});
const [playingId, setPlayingId] = useState<string | null>(null);
const [sounds, setSounds] = useState<Record<string, Audio.Sound>>({});
useEffect(() => {
const fetchAudioUrls = async () => {
const urls: Record<string, string> = {};
for (const [responderId, data] of Object.entries(responseSummary)) {
if (data.type === 'audio' && data.media_path) {
try {
const storagePath = getStoragePath(data.media_path);
const signedUrl = await getCachedSignedUrl(storagePath, 'response-media');
if (signedUrl) {
urls[responderId] = signedUrl;
}
} catch (err) {
console.error('Failed to get audio URL:', err);
}
}
}
setAudioUrls(urls);
};
fetchAudioUrls();
// Cleanup sounds on unmount
return () => {
Object.values(sounds).forEach(sound => {
sound.unloadAsync();
});
};
}, [responseSummary]);
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const handlePlayPause = async (responderId: string) => {
const audioUrl = audioUrls[responderId];
if (!audioUrl) return;
// If already playing this one, pause it
if (playingId === responderId && sounds[responderId]) {
await sounds[responderId].pauseAsync();
setPlayingId(null);
return;
}
// Stop any currently playing audio
if (playingId && sounds[playingId]) {
await sounds[playingId].stopAsync();
}
// Play the selected audio
try {
let sound = sounds[responderId];
if (!sound) {
const { sound: newSound } = await Audio.Sound.createAsync(
{ uri: audioUrl },
{ shouldPlay: false },
(status) => {
if (status.isLoaded && status.didJustFinish) {
setPlayingId(null);
}
}
);
sound = newSound;
setSounds(prev => ({ ...prev, [responderId]: sound }));
}
await sound.playFromPositionAsync(0);
setPlayingId(responderId);
} catch (err) {
console.error('Failed to play audio:', err);
}
};
const entries = Object.entries(responseSummary);
return (
<View style={styles.audioContainer}>
{entries.map(([responderId, data]) => {
if (data.type !== 'audio') return null;
const isUser = responderId === userId;
const name = isUser ? 'You' : partnerName;
const audioUrl = audioUrls[responderId];
const isPlaying = playingId === responderId;
return (
<View key={responderId} style={styles.audioCard}>
<Text style={styles.audioName}>{name}</Text>
<TouchableOpacity
style={styles.audioPlayer}
onPress={() => handlePlayPause(responderId)}
disabled={!audioUrl}
>
<View style={[
styles.audioPlayButton,
isPlaying && styles.audioPlayButtonActive
]}>
<Ionicons
name={isPlaying ? 'pause' : 'play'}
size={20}
color={colors.text}
/>
</View>
<View style={styles.audioWaveform}>
{[...Array(5)].map((_, i) => (
<View
key={i}
style={[
styles.audioBar,
{ height: 8 + (i % 3) * 8 },
isPlaying && styles.audioBarActive
]}
/>
))}
</View>
<Text style={styles.audioDuration}>
{formatDuration(data.duration_seconds)}
</Text>
</TouchableOpacity>
</View>
);
})}
</View>
);
};
const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user, userName = 'You', partnerName = 'Partner', onImagePress }) => {
// Track if animation has already played to prevent re-animation on re-renders // Track if animation has already played to prevent re-animation on re-renders
const hasAnimatedRef = useRef(false); const hasAnimatedRef = useRef(false);
const shouldAnimate = !hasAnimatedRef.current; const shouldAnimate = !hasAnimatedRef.current;
@@ -29,6 +380,16 @@ const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => {
if (!match?.question) return null; if (!match?.question) return null;
const isYesYes = match?.match_type === 'yes_yes'; const isYesYes = match?.match_type === 'yes_yes';
const isBothAnswered = match?.match_type === 'both_answered';
// Determine the response type from response_summary
const getResponseType = (): ResponseData['type'] | null => {
if (!match.response_summary) return null;
const firstResponse = Object.values(match.response_summary)[0];
return firstResponse?.type || null;
};
const responseType = getResponseType();
// Only animate on first render to prevent re-animation when list re-renders // Only animate on first render to prevent re-animation when list re-renders
const enteringAnimation = shouldAnimate const enteringAnimation = shouldAnimate
@@ -41,7 +402,7 @@ const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => {
style={styles.matchCardContainer} style={styles.matchCardContainer}
> >
<View style={styles.matchCard}> <View style={styles.matchCard}>
{/* Card gradient background */} {/* Card gradient background - removed for flat style
<LinearGradient <LinearGradient
colors={isYesYes colors={isYesYes
? ['rgba(212, 175, 55, 0.12)', 'rgba(184, 134, 11, 0.08)'] ? ['rgba(212, 175, 55, 0.12)', 'rgba(184, 134, 11, 0.08)']
@@ -51,8 +412,9 @@ const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => {
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
/> />
*/}
{/* Top glow for YES+YES */} {/* Top glow for YES+YES - removed
{isYesYes && ( {isYesYes && (
<LinearGradient <LinearGradient
colors={[`${ACCENT_RGBA}0.15)`, 'transparent']} colors={[`${ACCENT_RGBA}0.15)`, 'transparent']}
@@ -61,10 +423,11 @@ const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => {
end={{ x: 0.5, y: 1 }} end={{ x: 0.5, y: 1 }}
/> />
)} )}
*/}
{/* Match Label */} {/* Match Label */}
<Text style={[styles.matchLabel, isYesYes && styles.matchLabelYesYes]}> <Text style={[styles.matchLabel, isYesYes && styles.matchLabelYesYes]}>
{isYesYes ? "PERFECT MATCH" : "SOFT MATCH"} {isYesYes ? "PERFECT MATCH" : isBothAnswered ? "BOTH ANSWERED" : "SOFT MATCH"}
</Text> </Text>
{/* Decorative Separator */} {/* Decorative Separator */}
@@ -74,8 +437,57 @@ const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => {
marginVertical={spacing.md} marginVertical={spacing.md}
/> />
{/* Question and Responses - Show who answered what */} {/* Question and Responses - Show based on match type and response type */}
{match.responses && match.responses.length > 0 ? ( {isBothAnswered && match.response_summary && responseType ? (
// Render response summary for non-swipe question types
<View style={styles.responseSummaryWrapper}>
{/* Show the question text */}
<Text style={styles.matchQuestionText}>
"{match.question.text}"
</Text>
<DecorativeSeparator
variant="rose"
width="40%"
marginVertical={spacing.sm}
/>
{/* Render based on response type */}
{responseType === 'text_answer' && (
<TextAnswerDisplay
responseSummary={match.response_summary}
userId={user?.id || ''}
partnerName={partnerName}
/>
)}
{responseType === 'photo' && (
<PhotoDisplay
responseSummary={match.response_summary}
userId={user?.id || ''}
partnerName={partnerName}
onImagePress={onImagePress}
/>
)}
{responseType === 'audio' && (
<AudioDisplay
responseSummary={match.response_summary}
userId={user?.id || ''}
partnerName={partnerName}
/>
)}
{responseType === 'who_likely' && (
<WhoLikelyDisplay
responseSummary={match.response_summary}
userId={user?.id || ''}
userName={userName}
partnerName={partnerName}
/>
)}
</View>
) : match.responses && match.responses.length > 0 ? (
<View style={styles.responsesContainer}> <View style={styles.responsesContainer}>
{(() => { {(() => {
// Sort responses by created_at to determine who answered first // Sort responses by created_at to determine who answered first
@@ -134,18 +546,13 @@ const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => {
</Text> </Text>
)} )}
{/* Premium border */} {/* Premium border removed */}
<View style={[
styles.matchCardBorder,
isYesYes && styles.matchCardBorderYesYes
]} pointerEvents="none" />
</View> </View>
</Animated.View> </Animated.View>
); );
}; };
// Wrap with React.memo for performance // ...
export const MatchCard = React.memo(MatchCardComponent);
const styles = StyleSheet.create({ const styles = StyleSheet.create({
matchCardContainer: { matchCardContainer: {
@@ -153,6 +560,9 @@ const styles = StyleSheet.create({
marginTop: spacing.md, marginTop: spacing.md,
borderRadius: radius.lg, borderRadius: radius.lg,
overflow: 'hidden', overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.backgroundLight,
}, },
matchCard: { matchCard: {
borderRadius: radius.lg, borderRadius: radius.lg,
@@ -160,14 +570,81 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
alignItems: 'center', alignItems: 'center',
overflow: 'hidden', overflow: 'hidden',
backgroundColor: colors.backgroundLight,
}, },
matchCardGlow: { matchCardGlow: {
position: 'absolute', display: 'none',
top: 0,
left: 0,
right: 0,
height: 60,
}, },
// ...
responseCard: {
backgroundColor: colors.background, // Nested
borderRadius: radius.md,
padding: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
},
// ...
matchCardBorder: {
display: 'none',
},
matchCardBorderYesYes: {
display: 'none',
},
// ...
textBubbleUser: {
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.primary,
alignSelf: 'flex-end',
},
textBubblePartner: {
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
alignSelf: 'flex-start',
},
// ...
photoPlaceholder: {
width: '100%',
height: '100%',
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
},
// ...
whoLikelyCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: colors.background,
borderRadius: radius.md,
padding: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
},
// ...
audioPlayer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background,
borderRadius: radius.lg,
padding: spacing.sm,
gap: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
},
audioPlayButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.backgroundLight,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: colors.border,
},
matchLabel: { matchLabel: {
...typography.caption2, ...typography.caption2,
fontWeight: '700', fontWeight: '700',
@@ -189,13 +666,7 @@ const styles = StyleSheet.create({
width: '100%', width: '100%',
gap: spacing.sm, gap: spacing.sm,
}, },
responseCard: {
backgroundColor: 'rgba(255, 255, 255, 0.03)',
borderRadius: radius.md,
padding: spacing.sm,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.06)',
},
responseHeader: { responseHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -246,17 +717,151 @@ const styles = StyleSheet.create({
fontStyle: 'italic', fontStyle: 'italic',
lineHeight: 18, lineHeight: 18,
}, },
matchCardBorder: {
position: 'absolute',
top: 0,
left: 0, // Response summary styles for non-swipe question types
right: 0, responseSummaryWrapper: {
bottom: 0, width: '100%',
borderRadius: radius.lg, alignItems: 'center',
borderWidth: 1,
borderColor: `rgba(232, 164, 174, 0.2)`,
}, },
matchCardBorderYesYes: { responseSummaryContainer: {
borderColor: `${ACCENT_RGBA}0.25)`, width: '100%',
gap: spacing.sm,
},
// Text answer styles
textResponseCard: {
width: '100%',
gap: spacing.xs,
},
textResponseName: {
...typography.caption2,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 1,
},
textBubble: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
maxWidth: '100%',
},
textBubbleText: {
...typography.subhead,
color: colors.text,
fontStyle: 'italic',
lineHeight: 20,
},
// Photo styles
photoContainer: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.md,
width: '100%',
},
photoCard: {
alignItems: 'center',
gap: spacing.xs,
flex: 1,
maxWidth: 120,
},
photoName: {
...typography.caption2,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 1,
},
photoThumbnailContainer: {
width: 80,
height: 80,
borderRadius: radius.md,
overflow: 'hidden',
borderWidth: 2,
borderColor: 'rgba(232, 164, 174, 0.3)',
},
photoThumbnail: {
width: '100%',
height: '100%',
},
photoLoadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
justifyContent: 'center',
alignItems: 'center',
},
// Who likely styles
whoLikelyContainer: {
width: '100%',
gap: spacing.sm,
},
whoLikelyVoter: {
...typography.caption1,
fontWeight: '500',
color: colors.textSecondary,
},
whoLikelyChoiceBadge: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
whoLikelyChoiceText: {
...typography.caption1,
fontWeight: '700',
color: colors.text,
letterSpacing: 0.5,
},
// Audio display styles
audioContainer: {
width: '100%',
gap: spacing.md,
},
audioCard: {
width: '100%',
gap: spacing.xs,
},
audioName: {
...typography.caption1,
fontWeight: '600',
color: colors.textSecondary,
textAlign: 'center',
},
audioPlayButtonActive: {
backgroundColor: colors.primary,
},
audioWaveform: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 3,
height: 32,
},
audioBar: {
width: 4,
backgroundColor: 'rgba(232, 164, 174, 0.3)',
borderRadius: radius.full,
},
audioBarActive: {
backgroundColor: colors.primary,
},
audioDuration: {
...typography.caption1,
color: colors.textTertiary,
fontVariant: ['tabular-nums'],
minWidth: 35,
textAlign: 'right',
}, },
}); });
export { MatchCardComponent as MatchCard };
@@ -51,13 +51,14 @@ const MessageBubbleComponent = ({ isMe, index, children, onLongPress }: MessageB
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
> >
{/* Silk highlight */} {/* Silk highlight removed for flat look
<LinearGradient <LinearGradient
colors={['rgba(255, 255, 255, 0.15)', 'transparent']} colors={['rgba(255, 255, 255, 0.15)', 'transparent']}
style={styles.bubbleSilkHighlight} style={styles.bubbleSilkHighlight}
start={{ x: 0.5, y: 0 }} start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }} end={{ x: 0.5, y: 1 }}
/> />
*/}
{children} {children}
</LinearGradient> </LinearGradient>
</TouchableOpacity> </TouchableOpacity>
@@ -72,20 +73,22 @@ const MessageBubbleComponent = ({ isMe, index, children, onLongPress }: MessageB
accessibilityHint="Long press to report" accessibilityHint="Long press to report"
> >
<View style={[styles.bubble, styles.theirBubble]}> <View style={[styles.bubble, styles.theirBubble]}>
{/* Subtle gradient background */} {/* Subtle gradient background removed
<LinearGradient <LinearGradient
colors={['rgba(22, 33, 62, 0.6)', 'rgba(13, 13, 26, 0.8)']} colors={['rgba(22, 33, 62, 0.6)', 'rgba(13, 13, 26, 0.8)']}
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
/> />
{/* Silk highlight */} */}
{/* Silk highlight removed
<LinearGradient <LinearGradient
colors={[`${ACCENT_RGBA}0.08)`, 'transparent']} colors={[`${ACCENT_RGBA}0.08)`, 'transparent']}
style={styles.bubbleSilkHighlight} style={styles.bubbleSilkHighlight}
start={{ x: 0.5, y: 0 }} start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }} end={{ x: 0.5, y: 1 }}
/> />
*/}
{children} {children}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
@@ -126,13 +129,10 @@ const styles = StyleSheet.create({
theirBubble: { theirBubble: {
borderTopLeftRadius: radius.xs, borderTopLeftRadius: radius.xs,
borderWidth: 1, borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.15)`, borderColor: colors.border,
backgroundColor: colors.backgroundLight,
}, },
bubbleSilkHighlight: { bubbleSilkHighlight: {
position: 'absolute', display: 'none',
top: 0,
left: 0,
right: 0,
height: 30,
}, },
}); });
@@ -10,7 +10,7 @@ import { Ionicons } from '@expo/vector-icons';
import { colors, gradients, spacing, radius } from '../../../theme'; import { colors, gradients, spacing, radius } from '../../../theme';
import { MessageMeta } from './MessageMeta'; import { MessageMeta } from './MessageMeta';
import { ChatVideoPlayer } from './ChatVideoPlayer'; import { ChatVideoPlayer } from './ChatVideoPlayer';
import { supabase } from '../../../lib/supabase'; import { getCachedSignedUrl, getStoragePath } from '../../../lib/imageCache';
import type { Message } from '../types'; import type { Message } from '../types';
const ACCENT = colors.premium.gold; const ACCENT = colors.premium.gold;
@@ -67,14 +67,17 @@ const MessageContentComponent: React.FC<MessageContentProps> = ({
setMediaLoading(true); setMediaLoading(true);
setMediaError(false); setMediaError(false);
try { try {
const { data, error } = await supabase.storage const storagePath = getStoragePath(item.media_path!);
.from('chat-media') const signedUrl = await getCachedSignedUrl(storagePath, 'chat-media');
.createSignedUrl(item.media_path!, 3600); // 1 hour expiry
if (error) throw error; if (!signedUrl) {
setMediaUri(data.signedUrl); throw new Error('Missing signed URL for chat media');
}
setMediaUri(signedUrl);
} catch (err) { } catch (err) {
console.error('Failed to get media URL:', err); console.error('Failed to get media URL:', err);
setMediaUri(null);
setMediaError(true); setMediaError(true);
} finally { } finally {
setMediaLoading(false); setMediaLoading(false);
+4 -1
View File
@@ -1,4 +1,5 @@
import { Database } from '../../types/supabase'; import { Database } from '../../types/supabase';
import { ResponseData } from '@sauci/shared';
/** Message type from database */ /** Message type from database */
export type Message = Database['public']['Tables']['messages']['Row']; export type Message = Database['public']['Tables']['messages']['Row'];
@@ -15,8 +16,10 @@ export interface Match {
id: string; id: string;
question_id: string; question_id: string;
couple_id: string; couple_id: string;
match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe'; match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe' | 'both_answered';
created_at: string; created_at: string;
/** Summary of both partners' response data (keyed by user_id) for non-swipe questions */
response_summary?: Record<string, ResponseData> | null;
question?: { question?: {
id: string; id: string;
text: string; text: string;
@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, useWindowDimensions, Platform, Alert, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, ScrollView, useWindowDimensions, Platform, Alert, TouchableOpacity } from 'react-native';
import { BlurView } from 'expo-blur';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
@@ -9,9 +10,9 @@ import { LinearGradient } from 'expo-linear-gradient';
import { GradientBackground } from '../../components/ui'; import { GradientBackground } from '../../components/ui';
import { Paywall } from '../../components/paywall'; import { Paywall } from '../../components/paywall';
import { CompactHeader } from '../../components/discovery';
import { colors, featureColors, spacing, typography, radius } from '../../theme'; import { colors, featureColors, spacing, typography, radius } from '../../theme';
import { useAuthStore, useSubscriptionStore } from '../../store'; import { useAuthStore, useSubscriptionStore } from '../../store';
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen'; import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
@@ -29,12 +30,13 @@ export function ProfileScreen() {
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH; const isWideScreen = width > MAX_CONTENT_WIDTH;
const { user, partner, fetchUser, isAnonymous } = useAuthStore(); const { user, partner, couple, fetchUser, isAnonymous } = useAuthStore();
const { subscription } = useSubscriptionStore(); const { subscription } = useSubscriptionStore();
const settings = useProfileSettings(); const settings = useProfileSettings();
// Paywall state // Paywall state
const [showPaywall, setShowPaywall] = useState(false); const [showPaywall, setShowPaywall] = useState(false);
const [headerHeight, setHeaderHeight] = useState(130);
// Derived state // Derived state
const isOwnSubscription = subscription.isProUser || user?.is_premium; const isOwnSubscription = subscription.isProUser || user?.is_premium;
@@ -61,28 +63,31 @@ export function ProfileScreen() {
return ( return (
<GradientBackground> <GradientBackground>
<View
style={[styles.stickyHeader, isWideScreen && styles.stickyHeaderWide]}
onLayout={(e) => setHeaderHeight(e.nativeEvent.layout.height)}
>
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(14, 14, 17, 0.85)' }]} />
<CompactHeader
user={user}
partner={partner}
couple={couple}
label="Settings"
showGreeting={false}
showPartnerBadge={false}
/>
</View>
<ScrollView <ScrollView
style={styles.container} style={styles.container}
contentContainerStyle={[ contentContainerStyle={[
styles.contentContainer, styles.contentContainer,
isWideScreen && styles.contentContainerWide, isWideScreen && styles.contentContainerWide,
{ paddingTop: headerHeight },
]} ]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* Header with user info (compact) */}
<Animated.View
entering={FadeInDown.delay(100).duration(500)}
style={styles.header}
>
<Text style={styles.headerLabel}>YOUR SAUCI</Text>
<Text style={styles.title}>Settings</Text>
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
</Animated.View>
{/* Compact Profile Preview */} {/* Compact Profile Preview */}
<Animated.View <Animated.View
entering={FadeInDown.delay(150).duration(500)} entering={FadeInDown.delay(150).duration(500)}
@@ -213,16 +218,6 @@ export function ProfileScreen() {
showChevron={false} showChevron={false}
/> />
<View style={styles.divider} /> <View style={styles.divider} />
<MenuItem
icon="refresh-outline"
label="Reset Swipe Tutorial"
description="Show the tutorial again"
onPress={async () => {
await resetSwipeTutorial();
Alert.alert("Success", "Swipe tutorial has been reset.");
}}
/>
<View style={styles.divider} />
<MenuItem <MenuItem
icon="heart-outline" icon="heart-outline"
label="Reset Matches Tutorial" label="Reset Matches Tutorial"
@@ -289,59 +284,31 @@ const styles = StyleSheet.create({
width: '100%', width: '100%',
maxWidth: MAX_CONTENT_WIDTH, maxWidth: MAX_CONTENT_WIDTH,
}, },
header: { stickyHeader: {
paddingTop: Platform.OS === 'ios' ? 60 : 40, position: 'absolute',
paddingHorizontal: spacing.lg, top: 0,
paddingBottom: spacing.md, left: 0,
alignItems: 'center', right: 0,
zIndex: 10,
overflow: 'hidden',
}, },
headerLabel: { stickyHeaderWide: {
...typography.caption2, left: '50%',
fontWeight: '600', right: 'auto',
letterSpacing: 3, width: MAX_CONTENT_WIDTH,
color: colors.secondary, transform: [{ translateX: -MAX_CONTENT_WIDTH / 2 }],
marginBottom: spacing.xs,
},
title: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.md,
width: 100,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(155, 89, 182, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.secondary,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.sm,
opacity: 0.6,
}, },
// Profile Preview // Profile Preview
profilePreview: { profilePreview: {
marginHorizontal: spacing.lg, marginHorizontal: spacing.lg,
marginBottom: spacing.lg, marginBottom: spacing.lg,
backgroundColor: colors.glass.background, backgroundColor: colors.backgroundLight, // Flat background
borderRadius: radius.lg, borderRadius: radius.lg,
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.border, borderColor: colors.border,
overflow: 'hidden', overflow: 'hidden',
}, },
profilePreviewContent: { // ...
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
},
avatarGradient: { avatarGradient: {
width: 56, width: 56,
height: 56, height: 56,
@@ -349,7 +316,26 @@ const styles = StyleSheet.create({
padding: 2, padding: 2,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
// Removed shadow
}, },
// ...
versionBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.backgroundLight, // Flat background
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
borderWidth: 1,
borderColor: colors.border,
},
profilePreviewContent: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
},
avatar: { avatar: {
width: '100%', width: '100%',
height: '100%', height: '100%',
@@ -387,7 +373,7 @@ const styles = StyleSheet.create({
}, },
divider: { divider: {
height: 1, height: 1,
backgroundColor: colors.glass.border, backgroundColor: colors.border,
marginVertical: spacing.md, marginVertical: spacing.md,
}, },
// Version Badge // Version Badge
@@ -395,15 +381,7 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
marginTop: spacing.lg, marginTop: spacing.lg,
}, },
versionBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.glass.background,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
},
version: { version: {
...typography.caption1, ...typography.caption1,
color: colors.textTertiary, color: colors.textTertiary,
@@ -204,11 +204,11 @@ const styles = StyleSheet.create({
width: 48, width: 48,
height: 48, height: 48,
borderRadius: 24, borderRadius: 24,
backgroundColor: colors.glass.background, backgroundColor: colors.backgroundLight,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderWidth: 2, borderWidth: 2,
borderColor: colors.glass.border, borderColor: colors.border,
borderStyle: 'dashed', borderStyle: 'dashed',
}, },
rowTextContainer: { rowTextContainer: {
@@ -234,16 +234,20 @@ const styles = StyleSheet.create({
width: 36, width: 36,
height: 36, height: 36,
borderRadius: 18, borderRadius: 18,
backgroundColor: colors.glass.background, backgroundColor: colors.backgroundLight,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderWidth: 1,
borderColor: colors.border,
}, },
chevronContainer: { chevronContainer: {
width: 28, width: 28,
height: 28, height: 28,
borderRadius: 14, borderRadius: 14,
backgroundColor: colors.glass.background, backgroundColor: colors.backgroundLight,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderWidth: 1,
borderColor: colors.border,
}, },
}); });
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { View, Text, StyleSheet, Modal, Platform, KeyboardAvoidingView, TouchableOpacity, ActivityIndicator, TextInput, ViewStyle } from 'react-native'; import { View, Text, StyleSheet, Modal, Platform, KeyboardAvoidingView, TouchableOpacity, ActivityIndicator, TextInput, ViewStyle } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown } from 'react-native-reanimated'; import Animated, { FadeInDown } from 'react-native-reanimated';
import { BlurView } from 'expo-blur';
import { GlassCard, GlassButton } from '../../../components/ui'; import { GlassCard, GlassButton } from '../../../components/ui';
import { colors, spacing, radius, typography } from '../../../theme'; import { colors, spacing, radius, typography } from '../../../theme';
@@ -184,11 +183,6 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
transparent transparent
animationType="fade" animationType="fade"
onRequestClose={closeResetModal} onRequestClose={closeResetModal}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
> >
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
@@ -274,7 +268,6 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
</View> </View>
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</BlurView>
</Modal> </Modal>
{/* Delete Relationship Modal */} {/* Delete Relationship Modal */}
@@ -283,11 +276,6 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
transparent transparent
animationType="fade" animationType="fade"
onRequestClose={closeRelationshipModal} onRequestClose={closeRelationshipModal}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
> >
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
@@ -370,7 +358,6 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
</View> </View>
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</BlurView>
</Modal> </Modal>
{/* Delete Account Modal */} {/* Delete Account Modal */}
@@ -379,11 +366,6 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
transparent transparent
animationType="fade" animationType="fade"
onRequestClose={closeAccountModal} onRequestClose={closeAccountModal}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
> >
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
@@ -474,7 +456,6 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
</View> </View>
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</BlurView>
</Modal> </Modal>
</> </>
); );
@@ -577,7 +558,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.backgroundLight, backgroundColor: colors.backgroundLight,
borderRadius: radius.xl, borderRadius: radius.xl,
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.border, borderColor: colors.border,
}, },
modalHeader: { modalHeader: {
alignItems: "center", alignItems: "center",
@@ -644,7 +625,7 @@ const styles = StyleSheet.create({
color: colors.warning, color: colors.warning,
}, },
warningInput: { warningInput: {
backgroundColor: colors.glass.background, backgroundColor: colors.background,
borderRadius: radius.md, borderRadius: radius.md,
borderWidth: 1, borderWidth: 1,
borderColor: colors.warning, borderColor: colors.warning,
@@ -684,7 +665,7 @@ const styles = StyleSheet.create({
color: colors.error, color: colors.error,
}, },
confirmInput: { confirmInput: {
backgroundColor: colors.glass.background, backgroundColor: colors.background,
borderRadius: radius.md, borderRadius: radius.md,
borderWidth: 1, borderWidth: 1,
borderColor: colors.error, borderColor: colors.error,
@@ -700,12 +681,12 @@ const styles = StyleSheet.create({
}, },
cancelButton: { cancelButton: {
flex: 1, flex: 1,
backgroundColor: colors.glass.backgroundLight, backgroundColor: colors.background,
paddingVertical: spacing.md, paddingVertical: spacing.md,
borderRadius: radius.md, borderRadius: radius.md,
alignItems: "center", alignItems: "center",
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.borderLight, borderColor: colors.border,
}, },
cancelButtonText: { cancelButtonText: {
...typography.body, ...typography.body,
@@ -127,6 +127,8 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
marginRight: spacing.md, marginRight: spacing.md,
backgroundColor: 'rgba(239, 68, 68, 0.1)', backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderWidth: 1,
borderColor: 'rgba(239, 68, 68, 0.2)', // Added border
}, },
textContainer: { textContainer: {
flex: 1, flex: 1,
@@ -34,6 +34,7 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
onMatchesToggle: (value: boolean) => updatePreference('matches_enabled', value), onMatchesToggle: (value: boolean) => updatePreference('matches_enabled', value),
onMessagesToggle: (value: boolean) => updatePreference('messages_enabled', value), onMessagesToggle: (value: boolean) => updatePreference('messages_enabled', value),
onPartnerActivityToggle: (value: boolean) => updatePreference('partner_activity_enabled', value), onPartnerActivityToggle: (value: boolean) => updatePreference('partner_activity_enabled', value),
onNudgesToggle: (value: boolean) => updatePreference('nudges_enabled', value),
onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value), onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value),
onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value), onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value),
onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_enabled', value), onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_enabled', value),
@@ -91,6 +92,17 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
<View style={styles.itemDivider} /> <View style={styles.itemDivider} />
<SwitchItem
icon="hand-left-outline"
label="Partner Nudges"
description="When your partner sends you a reminder"
value={preferences.nudges_enabled}
onValueChange={handlers.onNudgesToggle}
disabled={isUpdating}
/>
<View style={styles.itemDivider} />
<SwitchItem <SwitchItem
icon="layers-outline" icon="layers-outline"
label="Pack Updates" label="Pack Updates"
@@ -133,7 +145,7 @@ const styles = StyleSheet.create({
marginBottom: spacing.md, marginBottom: spacing.md,
paddingTop: spacing.md, paddingTop: spacing.md,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: colors.glass.border, borderTopColor: colors.border,
}, },
subsectionLabel: { subsectionLabel: {
...typography.caption1, ...typography.caption1,
@@ -144,7 +156,7 @@ const styles = StyleSheet.create({
}, },
itemDivider: { itemDivider: {
height: 1, height: 1,
backgroundColor: colors.glass.border, backgroundColor: colors.border,
marginVertical: spacing.sm, marginVertical: spacing.sm,
}, },
}); });
@@ -58,7 +58,7 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
divider: { divider: {
height: 1, height: 1,
backgroundColor: colors.glass.border, backgroundColor: colors.border,
marginVertical: spacing.lg, marginVertical: spacing.lg,
}, },
}); });
@@ -155,11 +155,32 @@ const styles = StyleSheet.create({
padding: 3, padding: 3,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
shadowColor: featureColors.profile.accent, // Removed shadows for flat look
shadowOffset: { width: 0, height: 4 }, },
shadowOpacity: 0.3, // ...
shadowRadius: 8, avatarEditBadge: {
elevation: 6, position: 'absolute',
bottom: 0,
right: 0,
backgroundColor: colors.backgroundLight,
width: 28,
height: 28,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.background,
},
// ...
cancelEditButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.backgroundLight,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: colors.border,
}, },
avatarImage: { avatarImage: {
width: '100%', width: '100%',
@@ -191,19 +212,7 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
avatarEditBadge: {
position: 'absolute',
bottom: 0,
right: 0,
backgroundColor: colors.surfaceSolid,
width: 28,
height: 28,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.background,
},
profileInfo: { profileInfo: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
@@ -253,12 +262,5 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
marginLeft: spacing.xs, marginLeft: spacing.xs,
}, },
cancelEditButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.glass.highlight,
justifyContent: 'center',
alignItems: 'center',
},
}); });
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { View, Text, StyleSheet, Modal, Platform, KeyboardAvoidingView, TouchableOpacity, ActivityIndicator, TextInput, ViewStyle } from 'react-native'; import { View, Text, StyleSheet, Modal, Platform, KeyboardAvoidingView, TouchableOpacity, ActivityIndicator, TextInput, ViewStyle } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown } from 'react-native-reanimated'; import Animated, { FadeInDown } from 'react-native-reanimated';
import { BlurView } from 'expo-blur';
import { GlassCard, GlassButton } from '../../../components/ui'; import { GlassCard, GlassButton } from '../../../components/ui';
import { colors, spacing, radius, typography } from '../../../theme'; import { colors, spacing, radius, typography } from '../../../theme';
@@ -137,11 +136,6 @@ export const RelationshipDangerZone: React.FC<RelationshipDangerZoneProps> = ({
transparent transparent
animationType="fade" animationType="fade"
onRequestClose={closeResetModal} onRequestClose={closeResetModal}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
> >
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
@@ -227,7 +221,6 @@ export const RelationshipDangerZone: React.FC<RelationshipDangerZoneProps> = ({
</View> </View>
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</BlurView>
</Modal> </Modal>
{/* Delete Relationship Modal */} {/* Delete Relationship Modal */}
@@ -236,11 +229,6 @@ export const RelationshipDangerZone: React.FC<RelationshipDangerZoneProps> = ({
transparent transparent
animationType="fade" animationType="fade"
onRequestClose={closeRelationshipModal} onRequestClose={closeRelationshipModal}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
> >
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
@@ -323,7 +311,6 @@ export const RelationshipDangerZone: React.FC<RelationshipDangerZoneProps> = ({
</View> </View>
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</BlurView>
</Modal> </Modal>
</> </>
); );
@@ -426,7 +413,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.backgroundLight, backgroundColor: colors.backgroundLight,
borderRadius: radius.xl, borderRadius: radius.xl,
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.border, borderColor: colors.border,
}, },
modalHeader: { modalHeader: {
alignItems: "center", alignItems: "center",
@@ -493,7 +480,7 @@ const styles = StyleSheet.create({
color: colors.warning, color: colors.warning,
}, },
warningInput: { warningInput: {
backgroundColor: colors.glass.background, backgroundColor: colors.background,
borderRadius: radius.md, borderRadius: radius.md,
borderWidth: 1, borderWidth: 1,
borderColor: colors.warning, borderColor: colors.warning,
@@ -533,7 +520,7 @@ const styles = StyleSheet.create({
color: colors.error, color: colors.error,
}, },
confirmInput: { confirmInput: {
backgroundColor: colors.glass.background, backgroundColor: colors.background,
borderRadius: radius.md, borderRadius: radius.md,
borderWidth: 1, borderWidth: 1,
borderColor: colors.error, borderColor: colors.error,
@@ -549,12 +536,12 @@ const styles = StyleSheet.create({
}, },
cancelButton: { cancelButton: {
flex: 1, flex: 1,
backgroundColor: colors.glass.backgroundLight, backgroundColor: colors.background,
paddingVertical: spacing.md, paddingVertical: spacing.md,
borderRadius: radius.md, borderRadius: radius.md,
alignItems: "center", alignItems: "center",
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.borderLight, borderColor: colors.border,
}, },
cancelButtonText: { cancelButtonText: {
...typography.body, ...typography.body,
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { View, Text, StyleSheet, Modal, Platform, KeyboardAvoidingView, TouchableOpacity, ActivityIndicator, TextInput } from 'react-native'; import { View, Text, StyleSheet, Modal, Platform, KeyboardAvoidingView, TouchableOpacity, ActivityIndicator, TextInput } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown } from 'react-native-reanimated'; import Animated, { FadeInDown } from 'react-native-reanimated';
import { BlurView } from 'expo-blur';
import { GlassCard } from '../../../components/ui'; import { GlassCard } from '../../../components/ui';
import { colors, spacing, radius, typography } from '../../../theme'; import { colors, spacing, radius, typography } from '../../../theme';
@@ -66,11 +65,6 @@ export const ResetProgress: React.FC<ResetProgressProps> = ({ onResetProgress })
transparent transparent
animationType="fade" animationType="fade"
onRequestClose={() => !isResetting && setShowResetModal(false)} onRequestClose={() => !isResetting && setShowResetModal(false)}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
> >
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
@@ -159,7 +153,6 @@ export const ResetProgress: React.FC<ResetProgressProps> = ({ onResetProgress })
</View> </View>
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</BlurView>
</Modal> </Modal>
</> </>
); );
@@ -240,7 +233,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.backgroundLight, backgroundColor: colors.backgroundLight,
borderRadius: radius.xl, borderRadius: radius.xl,
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.border, borderColor: colors.border,
}, },
modalHeader: { modalHeader: {
alignItems: "center", alignItems: "center",
@@ -313,7 +306,7 @@ const styles = StyleSheet.create({
color: colors.warning, color: colors.warning,
}, },
confirmInput: { confirmInput: {
backgroundColor: colors.glass.background, backgroundColor: colors.background,
borderRadius: radius.md, borderRadius: radius.md,
borderWidth: 1, borderWidth: 1,
borderColor: colors.warning, borderColor: colors.warning,
@@ -329,12 +322,12 @@ const styles = StyleSheet.create({
}, },
cancelButton: { cancelButton: {
flex: 1, flex: 1,
backgroundColor: colors.glass.backgroundLight, backgroundColor: colors.background,
paddingVertical: spacing.md, paddingVertical: spacing.md,
borderRadius: radius.md, borderRadius: radius.md,
alignItems: "center", alignItems: "center",
borderWidth: 1, borderWidth: 1,
borderColor: colors.glass.borderLight, borderColor: colors.border,
}, },
cancelButtonText: { cancelButtonText: {
...typography.body, ...typography.body,
@@ -57,7 +57,9 @@ const styles = StyleSheet.create({
width: 40, width: 40,
height: 40, height: 40,
borderRadius: 20, borderRadius: 20,
backgroundColor: colors.glass.background, backgroundColor: colors.backgroundLight,
borderWidth: 1,
borderColor: colors.border,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
@@ -14,7 +14,7 @@ export interface SettingsSectionProps {
} }
/** /**
* Reusable settings section with title and glass card container. * Reusable settings section with title and solid card container.
*/ */
export function SettingsSection({ title, delay = 200, children }: SettingsSectionProps) { export function SettingsSection({ title, delay = 200, children }: SettingsSectionProps) {
return ( return (
@@ -243,23 +243,56 @@ const styles = StyleSheet.create({
opacity: 0.3, opacity: 0.3,
}, },
proCardInner: { proCardInner: {
backgroundColor: 'rgba(22, 33, 62, 0.8)', backgroundColor: colors.backgroundLight, // Solid/Flat background
margin: 1, margin: 1,
borderRadius: radius.lg - 1, borderRadius: radius.lg - 1,
padding: spacing.md, padding: spacing.md,
}, },
proHeader: { // ...
flexDirection: 'row',
alignItems: 'center',
},
proBadge: { proBadge: {
width: 36, width: 36,
height: 36, height: 36,
borderRadius: 18, borderRadius: 18,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
...shadows.glow(colors.premium.gold), // Removed glow
}, },
// ...
manageButton: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
},
// ...
proStatusRow: {
flexDirection: 'row',
marginTop: spacing.md,
paddingTop: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.border,
gap: spacing.lg,
},
// ...
freeCardInner: {
backgroundColor: colors.backgroundLight, // Solid/Flat background
margin: 1,
borderRadius: radius.lg - 1,
padding: spacing.lg,
},
// ...
ctaButtonContainer: {
borderRadius: radius.md,
overflow: 'hidden',
// Removed glow
},
proHeader: {
flexDirection: 'row',
alignItems: 'center',
},
proHeaderText: { proHeaderText: {
flex: 1, flex: 1,
marginLeft: spacing.md, marginLeft: spacing.md,
@@ -274,27 +307,13 @@ const styles = StyleSheet.create({
color: colors.textTertiary, color: colors.textTertiary,
marginTop: 2, marginTop: 2,
}, },
manageButton: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: `${colors.premium.gold}40`,
},
manageButtonText: { manageButtonText: {
...typography.subhead, ...typography.subhead,
color: colors.premium.gold, color: colors.premium.gold,
fontWeight: '600', fontWeight: '600',
}, },
proStatusRow: {
flexDirection: 'row',
marginTop: spacing.md,
paddingTop: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.glass.border,
gap: spacing.lg,
},
proStatusItem: { proStatusItem: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -313,12 +332,7 @@ const styles = StyleSheet.create({
freeBorderGradient: { freeBorderGradient: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
}, },
freeCardInner: {
backgroundColor: 'rgba(22, 33, 62, 0.85)',
margin: 1,
borderRadius: radius.lg - 1,
padding: spacing.lg,
},
freeHeader: { freeHeader: {
alignItems: 'center', alignItems: 'center',
marginBottom: spacing.lg, marginBottom: spacing.lg,
@@ -358,11 +372,7 @@ const styles = StyleSheet.create({
...typography.body, ...typography.body,
color: colors.text, color: colors.text,
}, },
ctaButtonContainer: {
borderRadius: radius.md,
overflow: 'hidden',
...shadows.glow(colors.premium.gold),
},
ctaButton: { ctaButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -16,7 +16,7 @@ import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeInDown } from 'react-native-reanimated'; import Animated, { FadeInDown } from 'react-native-reanimated';
import { GradientBackground, GlassCard, GlassButton } from '../../../components/ui'; import { GradientBackground } from '../../../components/ui';
import { colors, featureColors, spacing, typography, radius } from '../../../theme'; import { colors, featureColors, spacing, typography, radius } from '../../../theme';
import { useAuthStore } from '../../../store'; import { useAuthStore } from '../../../store';
import { supabase } from '../../../lib/supabase'; import { supabase } from '../../../lib/supabase';
@@ -53,7 +53,7 @@ const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
* Profile settings sub-screen for editing avatar, display name, and gender. * Profile settings sub-screen for editing avatar, display name, and gender.
*/ */
export function ProfileSettingsScreen() { export function ProfileSettingsScreen() {
const { user, fetchUser } = useAuthStore(); const { user, fetchUser, isAnonymous } = useAuthStore();
const settings = useProfileSettings(); const settings = useProfileSettings();
const authEmail = useAuthEmail(); const authEmail = useAuthEmail();
@@ -260,10 +260,12 @@ export function ProfileSettingsScreen() {
color={colors.textTertiary} color={colors.textTertiary}
style={styles.inputIcon} style={styles.inputIcon}
/> />
<Text style={styles.readOnlyText}>{authEmail || 'Loading...'}</Text> <Text style={styles.readOnlyText}>
{authEmail || (isAnonymous ? 'Guest account' : 'Loading...')}
</Text>
</View> </View>
<Text style={styles.inputHint}> <Text style={styles.inputHint}>
Email cannot be changed {isAnonymous ? 'Save your account to add an email' : 'Email cannot be changed'}
</Text> </Text>
</SettingsSection> </SettingsSection>
@@ -300,11 +302,41 @@ const styles = StyleSheet.create({
padding: 4, padding: 4,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
shadowColor: featureColors.profile.accent, // Removed shadows
shadowOffset: { width: 0, height: 4 }, },
shadowOpacity: 0.3, // ...
shadowRadius: 8, inputContainer: {
elevation: 6, flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background, // Flat
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: spacing.md,
},
// ...
readOnlyField: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.backgroundLight,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
opacity: 0.7,
},
// ...
genderOption: {
width: '48%',
alignItems: 'center',
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.border,
paddingVertical: spacing.md,
paddingHorizontal: spacing.sm,
gap: spacing.xs,
}, },
avatarImage: { avatarImage: {
width: '100%', width: '100%',
@@ -354,15 +386,7 @@ const styles = StyleSheet.create({
color: colors.textTertiary, color: colors.textTertiary,
}, },
// Input // Input
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
paddingHorizontal: spacing.md,
},
inputIcon: { inputIcon: {
marginRight: spacing.sm, marginRight: spacing.sm,
}, },
@@ -379,17 +403,7 @@ const styles = StyleSheet.create({
marginLeft: spacing.xs, marginLeft: spacing.xs,
}, },
// Read-only field // Read-only field
readOnlyField: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
opacity: 0.7,
},
readOnlyText: { readOnlyText: {
...typography.body, ...typography.body,
color: colors.textSecondary, color: colors.textSecondary,
@@ -405,17 +419,7 @@ const styles = StyleSheet.create({
flexWrap: 'wrap', flexWrap: 'wrap',
gap: spacing.sm, gap: spacing.sm,
}, },
genderOption: {
width: '48%',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.glass.border,
paddingVertical: spacing.md,
paddingHorizontal: spacing.sm,
gap: spacing.xs,
},
genderOptionSelected: { genderOptionSelected: {
borderColor: colors.primary, borderColor: colors.primary,
backgroundColor: colors.primaryLight, backgroundColor: colors.primaryLight,
@@ -510,7 +510,7 @@ const styles = StyleSheet.create({
}, },
modeToggleContainer: { modeToggleContainer: {
flexDirection: "row", flexDirection: "row",
backgroundColor: colors.glass.background, backgroundColor: colors.backgroundLight,
borderRadius: radius.md, borderRadius: radius.md,
padding: 4, padding: 4,
marginBottom: spacing.md, marginBottom: spacing.md,
@@ -60,7 +60,7 @@ const styles = StyleSheet.create({
}, },
divider: { divider: {
height: 1, height: 1,
backgroundColor: colors.glass.border, backgroundColor: colors.border,
marginVertical: spacing.md, marginVertical: spacing.md,
}, },
}); });
+8
View File
@@ -8,3 +8,11 @@ export type { UseTypingIndicatorConfig, UseTypingIndicatorReturn } from './useTy
export { useMessageSubscription } from './useMessageSubscription'; export { useMessageSubscription } from './useMessageSubscription';
export type { UseMessageSubscriptionConfig, UseMessageSubscriptionReturn } from './useMessageSubscription'; export type { UseMessageSubscriptionConfig, UseMessageSubscriptionReturn } from './useMessageSubscription';
export { useMediaSaver } from './useMediaSaver'; export { useMediaSaver } from './useMediaSaver';
export { useResponseMediaUpload } from './useResponseMediaUpload';
export type {
UseResponseMediaUploadOptions,
UseResponseMediaUploadReturn,
UploadResult,
} from './useResponseMediaUpload';
export { useAudioRecorder } from './useAudioRecorder';
export type { UseAudioRecorderOptions, RecordingState } from './useAudioRecorder';
+240
View File
@@ -0,0 +1,240 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { Alert } from 'react-native';
import { Audio } from 'expo-av';
export interface UseAudioRecorderOptions {
/** Maximum recording duration in seconds. Default: 60 */
maxDurationSeconds?: number;
/** Callback when recording completes (either manually or via auto-stop) */
onRecordingComplete?: (uri: string, durationSeconds: number) => void;
}
export type RecordingState = 'idle' | 'recording' | 'stopped';
const DEFAULT_OPTIONS: Required<Omit<UseAudioRecorderOptions, 'onRecordingComplete'>> = {
maxDurationSeconds: 60,
};
/**
* Hook for audio recording functionality using expo-av.
* Handles microphone permissions, recording lifecycle, and cleanup.
*
* @param options - Optional configuration for recording
* @returns Audio recorder state and controls
*/
export const useAudioRecorder = (options?: UseAudioRecorderOptions) => {
const settings = {
...DEFAULT_OPTIONS,
...options,
};
const [state, setState] = useState<RecordingState>('idle');
const [durationSeconds, setDurationSeconds] = useState(0);
const [recordingUri, setRecordingUri] = useState<string | null>(null);
// Refs to track recording instance and cleanup
const recordingRef = useRef<Audio.Recording | null>(null);
const onRecordingCompleteRef = useRef(options?.onRecordingComplete);
// Keep the callback ref up to date
useEffect(() => {
onRecordingCompleteRef.current = options?.onRecordingComplete;
}, [options?.onRecordingComplete]);
/**
* Request microphone permissions with user-friendly error handling.
* @returns true if granted, false otherwise
*/
const requestPermission = useCallback(async (): Promise<boolean> => {
const { status } = await Audio.requestPermissionsAsync();
if (status !== 'granted') {
Alert.alert(
'Microphone Permission',
'Please allow microphone access to record audio.'
);
return false;
}
return true;
}, []);
/**
* Stop the current recording and return the result.
* @returns Object with uri and duration, or null if no recording
*/
const stopRecording = useCallback(async (): Promise<{ uri: string; duration: number } | null> => {
const recording = recordingRef.current;
if (!recording) {
return null;
}
try {
// Get final status before stopping
const status = await recording.getStatusAsync();
const finalDurationMs = status.isRecording ? status.durationMillis : 0;
const finalDurationSeconds = Math.round(finalDurationMs / 1000);
// Stop and unload the recording
await recording.stopAndUnloadAsync();
// Reset audio mode to allow playback
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
});
const uri = recording.getURI();
recordingRef.current = null;
if (uri) {
setRecordingUri(uri);
setState('stopped');
setDurationSeconds(finalDurationSeconds);
// Call the completion callback if provided
if (onRecordingCompleteRef.current) {
onRecordingCompleteRef.current(uri, finalDurationSeconds);
}
return { uri, duration: finalDurationSeconds };
}
setState('idle');
return null;
} catch (error) {
console.error('Error stopping recording:', error);
recordingRef.current = null;
setState('idle');
return null;
}
}, []);
/**
* Start a new audio recording.
* @returns true if recording started successfully, false otherwise
*/
const startRecording = useCallback(async (): Promise<boolean> => {
// Don't start if already recording
if (recordingRef.current) {
return false;
}
try {
// Request permission
const hasPermission = await requestPermission();
if (!hasPermission) {
return false;
}
// Configure audio mode for recording
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true,
});
// Reset state
setDurationSeconds(0);
setRecordingUri(null);
setState('recording');
// Create and start the recording
const { recording } = await Audio.Recording.createAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY
);
recordingRef.current = recording;
// Set up status update callback to track duration
recording.setOnRecordingStatusUpdate((status) => {
if (status.isRecording) {
const currentSeconds = Math.round(status.durationMillis / 1000);
setDurationSeconds(currentSeconds);
// Auto-stop if max duration reached
if (currentSeconds >= settings.maxDurationSeconds) {
stopRecording();
}
}
});
// Set progress update interval (100ms for smooth duration updates)
recording.setProgressUpdateInterval(100);
return true;
} catch (error) {
console.error('Error starting recording:', error);
recordingRef.current = null;
setState('idle');
// Reset audio mode on error
try {
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
});
} catch {
// Ignore errors during cleanup
}
return false;
}
}, [requestPermission, settings.maxDurationSeconds, stopRecording]);
/**
* Reset the recording state to idle.
* Cleans up any existing recording.
*/
const resetRecording = useCallback(() => {
// Clean up any existing recording
if (recordingRef.current) {
try {
recordingRef.current.stopAndUnloadAsync().catch(() => {
// Ignore errors during cleanup
});
} catch {
// Ignore errors during cleanup
}
recordingRef.current = null;
}
setState('idle');
setDurationSeconds(0);
setRecordingUri(null);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (recordingRef.current) {
recordingRef.current.stopAndUnloadAsync().catch(() => {
// Ignore errors during cleanup
});
recordingRef.current = null;
}
// Reset audio mode
Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
}).catch(() => {
// Ignore errors during cleanup
});
};
}, []);
return {
/** Current recording state: 'idle', 'recording', or 'stopped' */
state,
/** Current recording duration in seconds */
durationSeconds,
/** URI of the recorded audio file (available when state is 'stopped') */
recordingUri,
/** Start a new recording. Returns true if successful. */
startRecording,
/** Stop the current recording. Returns the uri and duration, or null. */
stopRecording,
/** Reset state to idle and clean up any recording. */
resetRecording,
/** Request microphone permission. Returns true if granted. */
requestPermission,
};
};
@@ -0,0 +1,191 @@
import { useState, useCallback, useRef } from 'react';
import { Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
import { supabase } from '../lib/supabase';
export interface UseResponseMediaUploadOptions {
userId: string;
questionId: string;
}
export interface UploadResult {
success: boolean;
/** Storage path, e.g., "user123/question456_1706189234.jpg" */
mediaPath?: string;
error?: string;
}
export interface UseResponseMediaUploadReturn {
/** Whether an upload is in progress */
uploading: boolean;
/** Upload a photo to response-media storage */
uploadPhoto: (localUri: string) => Promise<UploadResult>;
/** Upload an audio file to response-media storage */
uploadAudio: (localUri: string, durationSeconds: number) => Promise<UploadResult>;
}
/**
* Hook for uploading response media (photos and audio) to Supabase storage.
* Uploads to the `response-media` bucket with path format: {userId}/{questionId}_{timestamp}.{ext}
*/
export function useResponseMediaUpload(
options: UseResponseMediaUploadOptions
): UseResponseMediaUploadReturn {
const [uploading, setUploading] = useState(false);
// Use ref for options to avoid stale closures
const optionsRef = useRef(options);
optionsRef.current = options;
const uploadPhoto = useCallback(async (localUri: string): Promise<UploadResult> => {
const { userId, questionId } = optionsRef.current;
if (!userId || !questionId) {
return { success: false, error: 'Missing userId or questionId' };
}
setUploading(true);
try {
let fileBody;
let ext = 'jpg';
if (Platform.OS === 'web') {
const response = await fetch(localUri);
const blob = await response.blob();
fileBody = blob;
// Detect extension from blob type
if (blob.type === 'image/png') ext = 'png';
else if (blob.type === 'image/webp') ext = 'webp';
else if (blob.type === 'image/gif') ext = 'gif';
} else {
const base64 = await FileSystem.readAsStringAsync(localUri, {
encoding: FileSystem.EncodingType.Base64,
});
fileBody = decode(base64);
// Try to get extension from URI
const uriExt = localUri.split('.').pop()?.toLowerCase();
if (uriExt && ['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(uriExt)) {
ext = uriExt === 'jpeg' ? 'jpg' : uriExt;
}
}
const timestamp = Date.now();
const mediaPath = `${userId}/${questionId}_${timestamp}.${ext}`;
const contentType = `image/${ext === 'jpg' ? 'jpeg' : ext}`;
const { error: uploadError } = await supabase.storage
.from('response-media')
.upload(mediaPath, fileBody, {
contentType,
upsert: false,
});
if (uploadError) {
console.error('Error uploading photo:', uploadError);
return { success: false, error: uploadError.message };
}
return { success: true, mediaPath };
} catch (error) {
console.error('Error uploading photo:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
} finally {
setUploading(false);
}
}, []);
const uploadAudio = useCallback(
async (localUri: string, _durationSeconds: number): Promise<UploadResult> => {
const { userId, questionId } = optionsRef.current;
if (!userId || !questionId) {
return { success: false, error: 'Missing userId or questionId' };
}
setUploading(true);
try {
let fileBody;
let ext = 'm4a';
let contentType = 'audio/m4a';
if (Platform.OS === 'web') {
const response = await fetch(localUri);
const blob = await response.blob();
fileBody = blob;
// Detect extension from blob type
if (blob.type === 'audio/mpeg' || blob.type === 'audio/mp3') {
ext = 'mp3';
contentType = 'audio/mpeg';
} else if (blob.type === 'audio/wav') {
ext = 'wav';
contentType = 'audio/wav';
} else if (blob.type === 'audio/webm') {
ext = 'webm';
contentType = 'audio/webm';
} else if (blob.type === 'audio/mp4' || blob.type === 'audio/x-m4a') {
ext = 'm4a';
contentType = 'audio/m4a';
}
} else {
const base64 = await FileSystem.readAsStringAsync(localUri, {
encoding: FileSystem.EncodingType.Base64,
});
fileBody = decode(base64);
// Try to get extension from URI
const uriExt = localUri.split('.').pop()?.toLowerCase();
if (uriExt && ['m4a', 'mp3', 'wav', 'aac', 'caf'].includes(uriExt)) {
ext = uriExt;
if (ext === 'mp3') contentType = 'audio/mpeg';
else if (ext === 'wav') contentType = 'audio/wav';
else if (ext === 'aac') contentType = 'audio/aac';
else if (ext === 'caf') contentType = 'audio/x-caf';
else contentType = 'audio/m4a';
}
}
const timestamp = Date.now();
const mediaPath = `${userId}/${questionId}_${timestamp}.${ext}`;
const { error: uploadError } = await supabase.storage
.from('response-media')
.upload(mediaPath, fileBody, {
contentType,
upsert: false,
});
if (uploadError) {
console.error('Error uploading audio:', uploadError);
return { success: false, error: uploadError.message };
}
return { success: true, mediaPath };
} catch (error) {
console.error('Error uploading audio:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
} finally {
setUploading(false);
}
},
[]
);
return {
uploading,
uploadPhoto,
uploadAudio,
};
}
+2 -2
View File
@@ -63,8 +63,8 @@ export async function getCachedSignedUrl(
export function getStoragePath(mediaPath: string): string { export function getStoragePath(mediaPath: string): string {
if (mediaPath.startsWith('http')) { if (mediaPath.startsWith('http')) {
// Old format: extract path from full URL // Old format: extract path from full URL
const match = mediaPath.match(/\/chat-media\/(.+)$/); const match = mediaPath.match(/\/(chat-media|response-media)\/(.+)$/);
return match ? match[1] : mediaPath; return match ? match[2] : mediaPath;
} }
return mediaPath; return mediaPath;
} }
+16 -1
View File
@@ -5,6 +5,7 @@ import { router } from "expo-router";
import { supabase } from "./supabase"; import { supabase } from "./supabase";
import { captureError } from "./sentry"; import { captureError } from "./sentry";
import { Events } from "./analytics"; import { Events } from "./analytics";
import { useMatchStore } from "../store/matchStore";
// Configure notification behavior for foreground // Configure notification behavior for foreground
// We don't show system alerts since the app handles notifications with in-app toasts // We don't show system alerts since the app handles notifications with in-app toasts
@@ -18,7 +19,7 @@ Notifications.setNotificationHandler({
// Types // Types
export interface NotificationData { export interface NotificationData {
type: "match" | "message" | "match_digest"; type: "match" | "message" | "match_digest" | "partner_activity" | "nudge";
match_id?: string; match_id?: string;
message_id?: string; message_id?: string;
count?: number; count?: number;
@@ -235,6 +236,20 @@ export function handleNotificationResponse(
return; return;
} }
if (data.type === "nudge") {
// Navigate to Matches > Your Turn tab
useMatchStore.getState().setCurrentView('pending');
router.push("/(app)/matches");
return;
}
if (data.type === "partner_activity") {
// Navigate to Matches > Your Turn tab (questions to answer)
useMatchStore.getState().setCurrentView('pending');
router.push("/(app)/matches");
return;
}
if (!data.match_id) { if (!data.match_id) {
return; return;
} }
@@ -0,0 +1,49 @@
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
const STORAGE_KEY_PREFIX = "onboarding_paywall_seen";
const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`;
async function getStorage(key: string): Promise<boolean> {
try {
let data: string | null = null;
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
data = window.localStorage.getItem(key);
}
} else {
data = await SecureStore.getItemAsync(key);
}
return data === "true";
} catch (error) {
console.error("Error reading onboarding paywall state:", error);
}
return false;
}
async function setStorage(key: string, seen: boolean): Promise<void> {
try {
const data = seen ? "true" : "false";
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(key, data);
}
} else {
await SecureStore.setItemAsync(key, data);
}
} catch (error) {
console.error("Error saving onboarding paywall state:", error);
}
}
export async function hasSeenOnboardingPaywall(userId: string): Promise<boolean> {
return getStorage(buildKey(userId));
}
export async function markOnboardingPaywallSeen(userId: string): Promise<void> {
await setStorage(buildKey(userId), true);
}
+11 -2
View File
@@ -145,7 +145,7 @@ class RevenueCatService {
return result.current; return result.current;
} }
async getOfferingsDebug(): Promise<{ async getOfferingsDebug(offeringIdentifier?: string): Promise<{
current: PurchasesOffering | null; current: PurchasesOffering | null;
availableOfferings: string[] | null; availableOfferings: string[] | null;
error: string | null; error: string | null;
@@ -162,8 +162,17 @@ class RevenueCatService {
const offerings = await this.Purchases.getOfferings(); const offerings = await this.Purchases.getOfferings();
const availableOfferings = Object.keys(offerings.all || {}); const availableOfferings = Object.keys(offerings.all || {});
// If a specific offering is requested, try to get it
let targetOffering = offerings.current;
if (offeringIdentifier && offerings.all?.[offeringIdentifier]) {
console.log(`RevenueCat: Using offering "${offeringIdentifier}"`);
targetOffering = offerings.all[offeringIdentifier];
} else if (offeringIdentifier) {
console.log(`RevenueCat: Offering "${offeringIdentifier}" not found, falling back to current`);
}
return { return {
current: offerings.current, current: targetOffering,
availableOfferings, availableOfferings,
error: null error: null
}; };
-51
View File
@@ -1,51 +0,0 @@
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
const STORAGE_KEY = "swipe_tutorial_seen";
async function getStorage(): Promise<boolean> {
try {
let data: string | null = null;
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
data = window.localStorage.getItem(STORAGE_KEY);
}
} else {
data = await SecureStore.getItemAsync(STORAGE_KEY);
}
return data === "true";
} catch (error) {
console.error("Error reading swipe tutorial state:", error);
}
return false;
}
async function setStorage(seen: boolean): Promise<void> {
try {
const data = seen ? "true" : "false";
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, data);
}
} else {
await SecureStore.setItemAsync(STORAGE_KEY, data);
}
} catch (error) {
console.error("Error saving swipe tutorial state:", error);
}
}
export async function hasSeenSwipeTutorial(): Promise<boolean> {
return getStorage();
}
export async function markSwipeTutorialSeen(): Promise<void> {
await setStorage(true);
}
export async function resetSwipeTutorial(): Promise<void> {
await setStorage(false);
}
+209 -1
View File
@@ -12,7 +12,19 @@ export interface PendingQuestion {
partnerAnsweredAt: string; partnerAnsweredAt: string;
} }
type MatchViewType = 'active' | 'archived' | 'pending'; type MatchViewType = 'active' | 'archived' | 'pending' | 'their_turn';
// Rate limit: 12 hours in milliseconds
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
interface NudgeResponse {
success: boolean;
notification_sent?: boolean;
reason?: string;
next_nudge_available_at?: string;
error?: string;
cooldown_remaining_seconds?: number;
}
interface MatchState { interface MatchState {
matches: Match[]; matches: Match[];
@@ -31,7 +43,13 @@ interface MatchState {
// Pending (Your Turn) state // Pending (Your Turn) state
pendingQuestions: PendingQuestion[]; pendingQuestions: PendingQuestion[];
isLoadingPending: boolean; isLoadingPending: boolean;
// Their Turn state (user answered, partner hasn't)
theirTurnQuestions: PendingQuestion[];
isLoadingTheirTurn: boolean;
currentView: MatchViewType; currentView: MatchViewType;
// Nudge state
nudgeCooldownUntil: Date | null;
isNudging: boolean;
// Methods // Methods
fetchMatches: (refresh?: boolean) => Promise<void>; fetchMatches: (refresh?: boolean) => Promise<void>;
markAsSeen: (matchId: string) => Promise<void>; markAsSeen: (matchId: string) => Promise<void>;
@@ -47,7 +65,11 @@ interface MatchState {
isMatchArchived: (matchId: string) => boolean; isMatchArchived: (matchId: string) => boolean;
// Pending methods // Pending methods
fetchPendingQuestions: () => Promise<void>; fetchPendingQuestions: () => Promise<void>;
fetchTheirTurnQuestions: () => Promise<void>;
setCurrentView: (view: MatchViewType) => void; setCurrentView: (view: MatchViewType) => void;
// Nudge methods
sendNudge: () => Promise<{ success: boolean; notificationSent: boolean }>;
checkNudgeCooldown: () => Promise<void>;
} }
export const useMatchStore = create<MatchState>((set, get) => ({ export const useMatchStore = create<MatchState>((set, get) => ({
@@ -67,7 +89,13 @@ export const useMatchStore = create<MatchState>((set, get) => ({
// Pending (Your Turn) state // Pending (Your Turn) state
pendingQuestions: [], pendingQuestions: [],
isLoadingPending: false, isLoadingPending: false,
// Their Turn state
theirTurnQuestions: [],
isLoadingTheirTurn: false,
currentView: 'pending' as MatchViewType, currentView: 'pending' as MatchViewType,
// Nudge state
nudgeCooldownUntil: null,
isNudging: false,
fetchMatches: async (refresh = false) => { fetchMatches: async (refresh = false) => {
const userId = useAuthStore.getState().user?.id; const userId = useAuthStore.getState().user?.id;
@@ -291,7 +319,11 @@ clearMatches: () => {
isLoadingArchived: false, isLoadingArchived: false,
pendingQuestions: [], pendingQuestions: [],
isLoadingPending: false, isLoadingPending: false,
theirTurnQuestions: [],
isLoadingTheirTurn: false,
currentView: 'pending', currentView: 'pending',
nudgeCooldownUntil: null,
isNudging: false,
}); });
}, },
@@ -547,6 +579,81 @@ clearMatches: () => {
} }
}, },
// Their Turn methods (user answered, partner hasn't yet)
fetchTheirTurnQuestions: async () => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId || !userId) {
set({ theirTurnQuestions: [], isLoadingTheirTurn: false });
return;
}
const state = get();
if (state.isLoadingTheirTurn) return;
set({ isLoadingTheirTurn: true });
try {
// Get partner's user_id
const { data: coupleProfiles } = await supabase
.from("profiles")
.select("id")
.eq("couple_id", coupleId)
.neq("id", userId);
const partnerId = coupleProfiles?.[0]?.id;
if (!partnerId) {
set({ theirTurnQuestions: [], isLoadingTheirTurn: false });
return;
}
// Get questions the partner has already answered
const { data: partnerResponses } = await supabase
.from("responses")
.select("question_id")
.eq("user_id", partnerId)
.eq("couple_id", coupleId);
const partnerAnsweredQuestionIds = new Set(partnerResponses?.map(r => r.question_id) ?? []);
// Get user's responses (questions they answered that partner hasn't)
const { data: userResponses, error } = await supabase
.from("responses")
.select(`
id,
question_id,
created_at,
question:questions(
*,
pack:question_packs(id, name, icon)
)
`)
.eq("user_id", userId)
.eq("couple_id", coupleId)
.order("created_at", { ascending: false });
if (error) throw error;
// Filter to only questions partner hasn't answered, excluding deleted questions
const theirTurnQuestions: PendingQuestion[] = (userResponses ?? [])
.filter(r => {
const question = r.question as unknown as PendingQuestion['question'] | null;
return !partnerAnsweredQuestionIds.has(r.question_id) && question && !(question as any).deleted_at;
})
.map(r => ({
id: r.id,
question: r.question as unknown as PendingQuestion['question'],
partnerAnsweredAt: r.created_at, // Using user's answer time here
}));
set({ theirTurnQuestions, isLoadingTheirTurn: false });
} catch (err) {
console.error("Error fetching their turn questions:", err);
set({ isLoadingTheirTurn: false });
}
},
setCurrentView: (view: MatchViewType) => { setCurrentView: (view: MatchViewType) => {
const state = get(); const state = get();
@@ -559,6 +666,107 @@ clearMatches: () => {
get().fetchArchivedMatches(); get().fetchArchivedMatches();
} else if (view === 'pending' && state.pendingQuestions.length === 0) { } else if (view === 'pending' && state.pendingQuestions.length === 0) {
get().fetchPendingQuestions(); get().fetchPendingQuestions();
} else if (view === 'their_turn' && state.theirTurnQuestions.length === 0) {
get().fetchTheirTurnQuestions();
}
},
// Nudge methods
sendNudge: async () => {
const state = get();
if (state.isNudging) {
return { success: false, notificationSent: false };
}
// Check if still in cooldown
if (state.nudgeCooldownUntil && new Date() < state.nudgeCooldownUntil) {
return { success: false, notificationSent: false };
}
set({ isNudging: true });
try {
const { data: sessionData } = await supabase.auth.getSession();
const token = sessionData?.session?.access_token;
if (!token) {
set({ isNudging: false });
return { success: false, notificationSent: false };
}
const response = await fetch(
`${process.env.EXPO_PUBLIC_SUPABASE_URL}/functions/v1/send-nudge-notification`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
}
);
const data: NudgeResponse = await response.json();
if (response.status === 429) {
// Rate limited - update cooldown
const cooldownUntil = data.next_nudge_available_at
? new Date(data.next_nudge_available_at)
: new Date(Date.now() + (data.cooldown_remaining_seconds || 0) * 1000);
set({ nudgeCooldownUntil: cooldownUntil, isNudging: false });
return { success: false, notificationSent: false };
}
if (!response.ok) {
console.error("Nudge error:", data.error);
set({ isNudging: false });
return { success: false, notificationSent: false };
}
// Success - update cooldown
const cooldownUntil = data.next_nudge_available_at
? new Date(data.next_nudge_available_at)
: new Date(Date.now() + NUDGE_COOLDOWN_MS);
set({ nudgeCooldownUntil: cooldownUntil, isNudging: false });
return {
success: true,
notificationSent: data.notification_sent ?? false,
};
} catch (err) {
console.error("Error sending nudge:", err);
set({ isNudging: false });
return { success: false, notificationSent: false };
}
},
checkNudgeCooldown: async () => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
try {
const { data: profile, error } = await supabase
.from("profiles")
.select("last_nudge_sent_at")
.eq("id", userId)
.maybeSingle();
if (error || !profile?.last_nudge_sent_at) {
set({ nudgeCooldownUntil: null });
return;
}
const lastNudge = new Date(profile.last_nudge_sent_at);
const cooldownUntil = new Date(lastNudge.getTime() + NUDGE_COOLDOWN_MS);
// Only set cooldown if it's still in the future
if (cooldownUntil > new Date()) {
set({ nudgeCooldownUntil: cooldownUntil });
} else {
set({ nudgeCooldownUntil: null });
}
} catch (err) {
console.error("Error checking nudge cooldown:", err);
} }
}, },
})); }));
@@ -7,6 +7,7 @@ export interface NotificationPreferences {
matches_enabled: boolean; matches_enabled: boolean;
messages_enabled: boolean; messages_enabled: boolean;
partner_activity_enabled: boolean; partner_activity_enabled: boolean;
nudges_enabled: boolean;
pack_changes_enabled: boolean; pack_changes_enabled: boolean;
new_packs_enabled: boolean; new_packs_enabled: boolean;
streak_milestones_enabled: boolean; streak_milestones_enabled: boolean;
+20 -3
View File
@@ -1,7 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { supabase } from "../lib/supabase"; import { supabase } from "../lib/supabase";
import { invokeWithAuthRetry } from "../lib/authErrorHandler"; import { invokeWithAuthRetry } from "../lib/authErrorHandler";
import type { Question, QuestionPack, AnswerType } from "@/types"; import type { Question, QuestionPack, AnswerType, ResponseData } from "@/types";
import { useAuthStore } from "./authStore"; import { useAuthStore } from "./authStore";
import { useMatchStore } from "./matchStore"; import { useMatchStore } from "./matchStore";
@@ -10,6 +10,7 @@ export interface ResponseWithQuestion {
id: string; id: string;
question_id: string; question_id: string;
answer: AnswerType; answer: AnswerType;
response_data?: ResponseData | null;
created_at: string; created_at: string;
question: Question & { question: Question & {
pack: Pick<QuestionPack, "id" | "name" | "icon">; pack: Pick<QuestionPack, "id" | "name" | "icon">;
@@ -49,7 +50,8 @@ interface ResponsesState {
updateResponse: ( updateResponse: (
questionId: string, questionId: string,
newAnswer: AnswerType, newAnswer: AnswerType,
confirmDelete?: boolean confirmDelete?: boolean,
responseData?: ResponseData | null
) => Promise<UpdateResponseResult>; ) => Promise<UpdateResponseResult>;
setGroupBy: (groupBy: GroupByOption) => void; setGroupBy: (groupBy: GroupByOption) => void;
setDateSortOrder: (order: DateSortOrder) => void; setDateSortOrder: (order: DateSortOrder) => void;
@@ -108,6 +110,7 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
id, id,
question_id, question_id,
answer, answer,
response_data,
created_at, created_at,
question:questions!inner( question:questions!inner(
id, id,
@@ -115,6 +118,8 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
partner_text, partner_text,
intensity, intensity,
pack_id, pack_id,
question_type,
config,
pack:question_packs!inner(id, name, icon) pack:question_packs!inner(id, name, icon)
) )
`) `)
@@ -180,6 +185,7 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
id: r.id, id: r.id,
question_id: r.question_id, question_id: r.question_id,
answer: r.answer as AnswerType, answer: r.answer as AnswerType,
response_data: (r as any).response_data as ResponseData | null,
created_at: r.created_at, created_at: r.created_at,
question: { question: {
id: question.id, id: question.id,
@@ -187,6 +193,8 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
partner_text: question.partner_text, partner_text: question.partner_text,
intensity: question.intensity, intensity: question.intensity,
pack_id: question.pack_id, pack_id: question.pack_id,
question_type: question.question_type,
config: question.config ?? null,
created_at: question.created_at || "", created_at: question.created_at || "",
pack: { pack: {
id: pack.id, id: pack.id,
@@ -219,13 +227,15 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
updateResponse: async ( updateResponse: async (
questionId: string, questionId: string,
newAnswer: AnswerType, newAnswer: AnswerType,
confirmDelete = false confirmDelete = false,
responseData?: ResponseData | null
): Promise<UpdateResponseResult> => { ): Promise<UpdateResponseResult> => {
const { data, error } = await invokeWithAuthRetry("update-response", { const { data, error } = await invokeWithAuthRetry("update-response", {
body: { body: {
question_id: questionId, question_id: questionId,
new_answer: newAnswer, new_answer: newAnswer,
confirm_delete_match: confirmDelete, confirm_delete_match: confirmDelete,
response_data: responseData,
}, },
}); });
@@ -242,9 +252,16 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
set((state) => ({ set((state) => ({
responses: state.responses.map((r) => { responses: state.responses.map((r) => {
if (r.question_id === questionId) { if (r.question_id === questionId) {
const nextResponseData =
newAnswer === "no"
? null
: typeof responseData !== "undefined"
? responseData
: r.response_data;
return { return {
...r, ...r,
answer: newAnswer, answer: newAnswer,
response_data: nextResponseData,
// Update match status based on result // Update match status based on result
has_match: result.match_deleted has_match: result.match_deleted
? false ? false
+3 -3
View File
@@ -23,7 +23,7 @@ interface SubscriptionStoreState {
// Actions // Actions
initializeRevenueCat: (userId: string) => Promise<void>; initializeRevenueCat: (userId: string) => Promise<void>;
fetchOfferings: () => Promise<void>; fetchOfferings: (offeringIdentifier?: string) => Promise<void>;
purchasePackage: (pkg: PurchasesPackage) => Promise<PurchaseResult>; purchasePackage: (pkg: PurchasesPackage) => Promise<PurchaseResult>;
restorePurchases: () => Promise<boolean>; restorePurchases: () => Promise<boolean>;
refreshSubscriptionStatus: () => Promise<void>; refreshSubscriptionStatus: () => Promise<void>;
@@ -77,7 +77,7 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
} }
}, },
fetchOfferings: async () => { fetchOfferings: async (offeringIdentifier?: string) => {
set({ isLoadingOfferings: true, error: null }); set({ isLoadingOfferings: true, error: null });
try { try {
// Check if RevenueCat is initialized // Check if RevenueCat is initialized
@@ -90,7 +90,7 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
return; return;
} }
const result = await revenueCatService.getOfferingsDebug(); const result = await revenueCatService.getOfferingsDebug(offeringIdentifier);
if (result.error) { if (result.error) {
set({ set({
+25 -22
View File
@@ -1,26 +1,29 @@
import { Platform } from 'react-native'; import { Platform } from 'react-native';
export const colors = { export const colors = {
// Backgrounds // Backgrounds - Updated to Flat/Admin Palette
background: '#0d0d1a', background: '#0e0e11', // hsl(240 10% 6%)
backgroundLight: '#1a1a2e', backgroundLight: '#17171c', // hsl(240 8% 10%)
surface: 'rgba(22, 33, 62, 0.6)', surface: '#17171c', // Mapped to card color
surfaceSolid: '#16213e', surfaceSolid: '#17171c',
// Glass effects // Border
border: '#303036', // hsl(240 6% 20%)
// Glass effects - Retained keys for compat, but mapped to flat/subtle values
glass: { glass: {
background: 'rgba(22, 33, 62, 0.4)', background: '#17171c', // Solid card color
backgroundLight: 'rgba(22, 33, 62, 0.6)', backgroundLight: '#222228', // Slightly lighter
border: 'rgba(255, 255, 255, 0.08)', border: '#303036',
borderLight: 'rgba(255, 255, 255, 0.12)', borderLight: '#404048',
highlight: 'rgba(255, 255, 255, 0.05)', highlight: 'transparent', // Removed highlight
}, },
// Brand // Brand
primary: '#e94560', primary: '#e1306c', // Adjusted to match admin rose approximately
primaryDark: '#c73a52', primaryDark: '#c12055',
primaryLight: 'rgba(233, 69, 96, 0.15)', primaryLight: 'rgba(225, 48, 108, 0.15)',
primaryGlow: 'rgba(233, 69, 96, 0.3)', primaryGlow: 'rgba(225, 48, 108, 0.3)',
secondary: '#9b59b6', secondary: '#9b59b6',
secondaryDark: '#8e44ad', secondaryDark: '#8e44ad',
secondaryLight: 'rgba(155, 89, 182, 0.15)', secondaryLight: 'rgba(155, 89, 182, 0.15)',
@@ -68,13 +71,13 @@ export const colors = {
}; };
export const gradients = { export const gradients = {
primary: ['#e94560', '#9b59b6'], primary: ['#e1306c', '#9b59b6'],
primarySubtle: ['rgba(233, 69, 96, 0.8)', 'rgba(155, 89, 182, 0.8)'], primarySubtle: ['rgba(225, 48, 108, 0.8)', 'rgba(155, 89, 182, 0.8)'],
primaryReverse: ['#9b59b6', '#e94560'], primaryReverse: ['#9b59b6', '#e1306c'],
primaryVertical: ['#e94560', '#9b59b6'], primaryVertical: ['#e1306c', '#9b59b6'],
background: ['#1a1a2e', '#0d0d1a'], background: ['#0e0e11', '#1f1417', '#0e0e11'], // Premium subtle gradient from admin
backgroundReverse: ['#0d0d1a', '#1a1a2e'], backgroundReverse: ['#0e0e11', '#1f1417', '#0e0e11'],
glass: ['rgba(255, 255, 255, 0.08)', 'rgba(255, 255, 255, 0)'], glass: ['#17171c', '#17171c'], // Solid fallback
success: ['#2ECC71', '#27ae60'], success: ['#2ECC71', '#27ae60'],
warning: ['#F39C12', '#e67e22'], warning: ['#F39C12', '#e67e22'],
error: ['#E74C3C', '#c0392b'], error: ['#E74C3C', '#c0392b'],
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more