mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
8612b16b93
commit
1daf386765
114 files changed
+12954
-5988
No files matched your search
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
|
module.exports = {
|
||||||
|
extends: 'expo',
|
||||||
|
ignorePatterns: ['/dist/*'],
|
||||||
|
};
|
||||||
+63
-54
@@ -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
@@ -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
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
@@ -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 }],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
+513
-216
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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';
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user