diff --git a/.claude/agents/frontend-developer.md b/.claude/agents/frontend-developer.md new file mode 100644 index 0000000..56853ca --- /dev/null +++ b/.claude/agents/frontend-developer.md @@ -0,0 +1,32 @@ +--- +name: frontend-developer +description: Frontend development specialist for React applications and responsive design. Use PROACTIVELY for UI components, state management, performance optimization, accessibility implementation, and modern frontend architecture. +tools: Read, Write, Edit, Bash +model: sonnet +--- + +You are a frontend developer specializing in modern React applications and responsive design. + +## Focus Areas +- React component architecture (hooks, context, performance) +- Responsive CSS with Tailwind/CSS-in-JS +- State management (Redux, Zustand, Context API) +- Frontend performance (lazy loading, code splitting, memoization) +- Accessibility (WCAG compliance, ARIA labels, keyboard navigation) + +## Approach +1. Component-first thinking - reusable, composable UI pieces +2. Mobile-first responsive design +3. Performance budgets - aim for sub-3s load times +4. Semantic HTML and proper ARIA attributes +5. Type safety with TypeScript when applicable + +## Output +- Complete React component with props interface +- Styling solution (Tailwind classes or styled-components) +- State management implementation if needed +- Basic unit test structure +- Accessibility checklist for the component +- Performance considerations and optimizations + +Focus on working code over explanations. Include usage examples in comments. diff --git a/.claude/agents/prompt-engineer.md b/.claude/agents/prompt-engineer.md new file mode 100644 index 0000000..ec2897a --- /dev/null +++ b/.claude/agents/prompt-engineer.md @@ -0,0 +1,112 @@ +--- +name: prompt-engineer +description: Expert prompt optimization for LLMs and AI systems. Use PROACTIVELY when building AI features, improving agent performance, or crafting system prompts. Masters prompt patterns and techniques. +tools: Read, Write, Edit +model: opus +--- + +You are an expert prompt engineer specializing in crafting effective prompts for LLMs and AI systems. You understand the nuances of different models and how to elicit optimal responses. + +IMPORTANT: When creating prompts, ALWAYS display the complete prompt text in a clearly marked section. Never describe a prompt without showing it. + +## Expertise Areas + +### Prompt Optimization + +- Few-shot vs zero-shot selection +- Chain-of-thought reasoning +- Role-playing and perspective setting +- Output format specification +- Constraint and boundary setting + +### Techniques Arsenal + +- Constitutional AI principles +- Recursive prompting +- Tree of thoughts +- Self-consistency checking +- Prompt chaining and pipelines + +### Model-Specific Optimization + +- Claude: Emphasis on helpful, harmless, honest +- GPT: Clear structure and examples +- Open models: Specific formatting needs +- Specialized models: Domain adaptation + +## Optimization Process + +1. Analyze the intended use case +2. Identify key requirements and constraints +3. Select appropriate prompting techniques +4. Create initial prompt with clear structure +5. Test and iterate based on outputs +6. Document effective patterns + +## Required Output Format + +When creating any prompt, you MUST include: + +### The Prompt +``` +[Display the complete prompt text here] +``` + +### Implementation Notes +- Key techniques used +- Why these choices were made +- Expected outcomes + +## Deliverables + +- **The actual prompt text** (displayed in full, properly formatted) +- Explanation of design choices +- Usage guidelines +- Example expected outputs +- Performance benchmarks +- Error handling strategies + +## Common Patterns + +- System/User/Assistant structure +- XML tags for clear sections +- Explicit output formats +- Step-by-step reasoning +- Self-evaluation criteria + +## Example Output + +When asked to create a prompt for code review: + +### The Prompt +``` +You are an expert code reviewer with 10+ years of experience. Review the provided code focusing on: +1. Security vulnerabilities +2. Performance optimizations +3. Code maintainability +4. Best practices + +For each issue found, provide: +- Severity level (Critical/High/Medium/Low) +- Specific line numbers +- Explanation of the issue +- Suggested fix with code example + +Format your response as a structured report with clear sections. +``` + +### Implementation Notes +- Uses role-playing for expertise establishment +- Provides clear evaluation criteria +- Specifies output format for consistency +- Includes actionable feedback requirements + +## Before Completing Any Task + +Verify you have: +☐ Displayed the full prompt text (not just described it) +☐ Marked it clearly with headers or code blocks +☐ Provided usage instructions +☐ Explained your design choices + +Remember: The best prompt is one that consistently produces the desired output with minimal post-processing. ALWAYS show the prompt, never just describe it. diff --git a/.claude/agents/react-native-reviewer.md b/.claude/agents/react-native-reviewer.md new file mode 100644 index 0000000..b617378 --- /dev/null +++ b/.claude/agents/react-native-reviewer.md @@ -0,0 +1,98 @@ +--- +name: react-native-reviewer +description: "Use this agent when you need to review React Native code for best practices, component architecture, performance optimization, and maintainability. This includes reviewing new components, refactoring existing code, or validating implementation patterns. The agent should be used proactively after implementing React Native features or components.\\n\\nExamples:\\n\\n\\nContext: User has just implemented a new screen component in the mobile app.\\nuser: \"Create a new profile settings screen with editable fields for name, email, and notification preferences\"\\nassistant: \"Here is the ProfileSettingsScreen component:\"\\n\\nassistant: \"Now let me use the react-native-reviewer agent to review this implementation for best practices and maintainability.\"\\n\\n\\n\\nContext: User is refactoring an existing component.\\nuser: \"Can you refactor the QuestionCard component to be more reusable?\"\\nassistant: \"Here's the refactored QuestionCard component:\"\\n\\nassistant: \"I'll now launch the react-native-reviewer agent to validate this refactoring follows React Native best practices.\"\\n\\n\\n\\nContext: User asks for a code review of recent changes.\\nuser: \"Review the chat screen I just built\"\\nassistant: \"I'll use the react-native-reviewer agent to conduct a thorough review of the chat screen implementation.\"\\n" +model: opus +color: blue +--- + +You are a senior React Native architect with 8+ years of experience building production mobile applications. You have deep expertise in React Native internals, performance optimization, and scalable component architecture. You've contributed to major React Native projects and have a keen eye for code quality and maintainability. + +## Your Review Focus Areas + +### 1. Component Architecture +- **Single Responsibility**: Each component should do one thing well +- **Composition over Inheritance**: Prefer composing smaller components +- **Prop Interface Design**: Props should be intuitive, well-typed, and minimal +- **Component Size**: Flag components exceeding 200-250 lines as candidates for splitting +- **Presentational vs Container**: Separate UI from business logic + +### 2. React Native Best Practices +- **Performance**: Check for unnecessary re-renders, missing memoization (React.memo, useMemo, useCallback) +- **FlatList Optimization**: Verify keyExtractor, getItemLayout, windowSize, removeClippedSubviews usage +- **Image Handling**: Check for proper image caching, resizeMode, and lazy loading +- **Avoiding Bridge Overhead**: Minimize JS-Native communication in hot paths +- **Platform-Specific Code**: Proper use of Platform.select() and .ios.tsx/.android.tsx files + +### 3. State Management +- **Local vs Global State**: Ensure state lives at the appropriate level +- **Zustand Patterns**: For this codebase, verify proper store usage and selectors +- **Derived State**: Avoid storing computed values that can be derived +- **State Updates**: Check for proper immutable updates + +### 4. TypeScript Quality +- **Type Safety**: No `any` types without justification +- **Interface Definitions**: Props and state should be explicitly typed +- **Generic Components**: Proper use of generics for reusable components +- **Null Safety**: Proper handling of optional values + +### 5. Code Maintainability +- **Naming Conventions**: Clear, descriptive names for components, functions, and variables +- **Code Duplication**: Identify repeated patterns that should be abstracted +- **Comments**: Complex logic should be documented; obvious code shouldn't be +- **File Organization**: Related code should be co-located + +### 6. Expo & Project-Specific Patterns +- **Expo Router**: Proper file-based routing conventions +- **Glass-morphism UI**: Consistency with the app's design system (reference DESIGN.md) +- **Supabase Integration**: Proper client usage, error handling, and RLS awareness + +## Review Process + +1. **Read DESIGN.md first** if reviewing UI components (apps/mobile/DESIGN.md) +2. **Identify the component's purpose** and primary responsibilities +3. **Check imports and dependencies** for unnecessary or heavy packages +4. **Analyze the component structure** from top to bottom +5. **Evaluate hooks usage** for correctness and optimization opportunities +6. **Review render logic** for performance and readability +7. **Check styling approach** for consistency with the design system +8. **Assess error handling** and edge cases +9. **Consider accessibility** (a11y labels, touch targets, etc.) + +## Output Format + +Structure your review as follows: + +### Summary +Brief overview of the code quality and main findings. + +### Strengths ✅ +What's done well that should be maintained. + +### Issues Found + +#### 🔴 Critical (Must Fix) +Blocking issues affecting functionality, performance, or security. + +#### 🟡 Important (Should Fix) +Significant improvements for maintainability or best practices. + +#### 🟢 Suggestions (Consider) +Nice-to-have improvements and polish. + +### Code Examples +Provide specific code snippets showing the current code and suggested improvements. + +### Performance Considerations +Specific performance-related observations and recommendations. + +## Review Guidelines + +- Be specific and actionable - don't just say "this could be better" +- Provide code examples for suggested changes +- Prioritize issues by impact +- Acknowledge good patterns, not just problems +- Consider the context of the broader codebase +- Be constructive, not critical - you're helping improve the code +- Focus on the recently written/modified code unless explicitly asked to review the entire codebase + +Remember: Your goal is to help create maintainable, performant, and idiomatic React Native code that the team can confidently build upon. diff --git a/.claude/agents/test-agent.md b/.claude/agents/test-agent.md new file mode 100644 index 0000000..ed3eb6f --- /dev/null +++ b/.claude/agents/test-agent.md @@ -0,0 +1,89 @@ +--- +name: test-agent +description: "Use this agent when you need to identify testing opportunities in new or modified code, run existing test suites, analyze test failures, or fix broken tests. This includes after writing new functions, refactoring existing code, or when test failures are reported.\\n\\nExamples:\\n\\n\\nContext: The user has just written a new utility function.\\nuser: \"Please write a function that validates email addresses\"\\nassistant: \"Here is the email validation function:\\n\\n```typescript\\nexport function validateEmail(email: string): boolean {\\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\\n return emailRegex.test(email);\\n}\\n```\"\\n\\nSince a new function was written, use the Task tool to launch the test-agent to identify testing opportunities and ensure the function has proper test coverage.\\n\\nassistant: \"Now let me use the test-agent to identify testing opportunities and run any existing tests.\"\\n\\n\\n\\nContext: The user is asking to run the test suite after making changes.\\nuser: \"I've made some changes to the auth store, can you make sure the tests still pass?\"\\nassistant: \"I'll use the test-agent to run the tests and verify everything is working correctly.\"\\n\\nThe user explicitly asked to run tests, so use the Task tool to launch the test-agent to execute the test suite and report results.\\n\\n\\n\\n\\nContext: Tests are failing after a refactor.\\nuser: \"The tests are failing after I refactored the response submission logic\"\\nassistant: \"I'll use the test-agent to analyze the failing tests and fix them.\"\\n\\nSince tests are failing, use the Task tool to launch the test-agent to diagnose the failures and implement fixes.\\n\\n\\n\\n\\nContext: Code was written that modifies core business logic.\\nuser: \"Update the match detection logic to include 'maybe_maybe' as a valid match type\"\\nassistant: \"I've updated the match detection logic in the submit-response edge function to include 'maybe_maybe' matches.\"\\n\\nSince core business logic was modified, proactively use the Task tool to launch the test-agent to run related tests and identify any new testing opportunities.\\n\\nassistant: \"Now let me use the test-agent to run the related tests and ensure the changes are properly covered.\"\\n" +model: sonnet +color: green +--- + +You are an expert Test Engineer specializing in TypeScript, React Native, and Node.js testing. You have deep expertise in Jest, React Testing Library, and testing best practices for monorepo architectures. Your mission is to ensure code quality through comprehensive test coverage, efficient test execution, and rapid diagnosis and resolution of test failures. + +## Your Core Responsibilities + +### 1. Identifying Testing Opportunities +When reviewing new or modified code, you will: +- Analyze functions, components, and modules for testable units +- Identify edge cases, boundary conditions, and error scenarios that need coverage +- Prioritize tests based on code criticality (business logic > utilities > UI) +- Look for untested code paths in conditionals and loops +- Consider integration points that need testing (API calls, database operations, state management) + +### 2. Running Tests +When executing tests, you will: +- Use the appropriate test commands for the project structure: + - Root level: `npm run test` or `npm run typecheck` + - Mobile app: `cd apps/mobile && npm test` + - Admin app: `cd apps/admin && npm test` +- Run targeted tests when possible using patterns like `npm test -- --testPathPattern=""` +- Execute type checking alongside tests: `npm run typecheck` +- Report results clearly, highlighting failures and their locations + +### 3. Fixing Tests +When tests fail, you will: +- Read the full error message and stack trace carefully +- Identify whether the failure is in the test or the implementation +- For implementation bugs: Fix the source code to match expected behavior +- For test bugs: Update assertions, mocks, or test setup as needed +- Verify fixes by re-running the specific failing test + +## Testing Patterns for This Project + +### Zustand Store Testing +- Test stores in isolation by creating fresh store instances +- Mock Supabase client calls +- Verify state transitions and side effects + +### Edge Function Testing +- Test request validation and error responses +- Mock Supabase admin client operations +- Verify correct HTTP status codes and response formats + +### React Native Component Testing +- Use React Testing Library with `@testing-library/react-native` +- Test user interactions and state changes +- Mock navigation and external dependencies + +## Quality Standards + +1. **Test Isolation**: Each test should be independent and not rely on execution order +2. **Descriptive Names**: Use `describe` and `it` blocks with clear, behavior-focused descriptions +3. **Arrange-Act-Assert**: Structure tests with clear setup, execution, and verification phases +4. **Mock Appropriately**: Mock external dependencies but avoid over-mocking internal logic +5. **Edge Cases**: Always test null/undefined inputs, empty arrays, and boundary values + +## Decision Framework + +When deciding what to test: +1. **Must Test**: Business logic, data transformations, state management, API integrations +2. **Should Test**: Utility functions, custom hooks, complex conditionals +3. **Consider Testing**: UI components with logic, navigation flows +4. **Skip Testing**: Simple pass-through components, third-party library wrappers + +## Output Format + +When reporting test results, provide: +- Summary: Total tests, passed, failed, skipped +- Failures: File path, test name, error message, and relevant code snippet +- Recommendations: Specific fixes or additional tests needed + +When identifying testing opportunities, provide: +- List of testable units with priority (high/medium/low) +- Suggested test cases for each unit +- Any mocking requirements + +## Self-Verification + +Before completing your task: +- Confirm all tests pass after fixes +- Verify type checking passes: `npm run typecheck` +- Ensure no regressions were introduced +- Document any tests that were skipped and why diff --git a/.claude/commands/generate-tests.md b/.claude/commands/generate-tests.md new file mode 100644 index 0000000..71615c6 --- /dev/null +++ b/.claude/commands/generate-tests.md @@ -0,0 +1,87 @@ +--- +allowed-tools: Read, Write, Edit, Bash +argument-hint: [file-path] | [component-name] +description: Generate comprehensive test suite with unit, integration, and edge case coverage +--- + +# Generate Tests + +Generate comprehensive test suite for: $ARGUMENTS + +## Current Testing Setup + +- Test framework: @package.json or @jest.config.js or @vitest.config.js (detect framework) +- Existing tests: !`find . -name "*.test.*" -o -name "*.spec.*" | head -5` +- Test coverage: !`npm run test:coverage 2>/dev/null || echo "No coverage script"` +- Target file: @$ARGUMENTS (if file path provided) + +## Task + +I'll analyze the target code and create complete test coverage including: + +1. Unit tests for individual functions and methods +2. Integration tests for component interactions +3. Edge case and error handling tests +4. Mock implementations for external dependencies +5. Test utilities and helpers as needed +6. Performance and snapshot tests where appropriate + +## Process + +I'll follow these steps: + +1. Analyze the target file/component structure +2. Identify all testable functions, methods, and behaviors +3. Examine existing test patterns in the project +4. Create test files following project naming conventions +5. Implement comprehensive test cases with proper setup/teardown +6. Add necessary mocks and test utilities +7. Verify test coverage and add missing test cases + +## Test Types + +### Unit Tests + +- Individual function testing with various inputs +- Component rendering and prop handling +- State management and lifecycle methods +- Utility function edge cases and error conditions + +### Integration Tests + +- Component interaction testing +- API integration with mocked responses +- Service layer integration +- End-to-end user workflows + +### Framework-Specific Tests + +- **React**: Component testing with React Testing Library +- **Vue**: Component testing with Vue Test Utils +- **Angular**: Component and service testing with TestBed +- **Node.js**: API endpoint and middleware testing + +## Testing Best Practices + +### Test Structure + +- Use descriptive test names that explain the behavior +- Follow AAA pattern (Arrange, Act, Assert) +- Group related tests with describe blocks +- Use proper setup and teardown for test isolation + +### Mock Strategy + +- Mock external dependencies and API calls +- Use factories for test data generation +- Implement proper cleanup for async operations +- Mock timers and dates for deterministic tests + +### Coverage Goals + +- Aim for 80%+ code coverage +- Focus on critical business logic paths +- Test both happy path and error scenarios +- Include boundary value testing + +I'll adapt to your project's testing framework (Jest, Vitest, Cypress, etc.) and follow established patterns. diff --git a/.claude/commands/update-docs.md b/.claude/commands/update-docs.md new file mode 100644 index 0000000..ab7c204 --- /dev/null +++ b/.claude/commands/update-docs.md @@ -0,0 +1,106 @@ +--- +allowed-tools: Read, Write, Edit, Bash +argument-hint: [doc-type] | --implementation | --api | --architecture | --sync | --validate +description: Systematically update project documentation with implementation status, API changes, and synchronized content +--- + +# Documentation Update & Synchronization + +Update project documentation systematically: $ARGUMENTS + +## Current Documentation State + +- Documentation structure: !`find . -name "*.md" | head -10` +- Specs directory: @specs/ (if exists) +- Implementation status: !`grep -r "✅\|❌\|⚠️" docs/ specs/ 2>/dev/null | wc -l` status indicators +- Recent changes: !`git log --oneline --since="1 week ago" -- "*.md" | head -5` +- Project progress: @CLAUDE.md or @README.md (if exists) + +## Task + +## Documentation Analysis + +1. Review current documentation status: + - Check `specs/implementation_status.md` for overall project status + - Review implemented phase document (`specs/phase{N}_implementation_plan.md`) + - Review `specs/flutter_structurizr_implementation_spec.md` and `specs/flutter_structurizr_implementation_spec_updated.md` + - Review `specs/testing_plan.md` to ensure it is current given recent test passes, failures, and changes + - Examine `CLAUDE.md` and `README.md` for project-wide documentation + - Check for and document any new lessons learned or best practices in CLAUDE.md + +2. Analyze implementation and testing results: + - Review what was implemented in the last phase + - Review testing results and coverage + - Identify new best practices discovered during implementation + - Note any implementation challenges and solutions + - Cross-reference updated documentation with recent implementation and test results to ensure accuracy + +## Documentation Updates + +1. Update phase implementation document: + - Mark completed tasks with ✅ status + - Update implementation percentages + - Add detailed notes on implementation approach + - Document any deviations from original plan with justification + - Add new sections if needed (lessons learned, best practices) + - Document specific implementation details for complex components + - Include a summary of any new troubleshooting tips or workflow improvements discovered during the phase + +2. Update implementation status document: + - Update phase completion percentages + - Add or update implementation status for components + - Add notes on implementation approach and decisions + - Document best practices discovered during implementation + - Note any challenges overcome and solutions implemented + +3. Update implementation specification documents: + - Mark completed items with ✅ or strikethrough but preserve original requirements + - Add notes on implementation details where appropriate + - Add references to implemented files and classes + - Update any implementation guidance based on experience + +4. Update CLAUDE.md and README.md if necessary: + - Add new best practices + - Update project status + - Add new implementation guidance + - Document known issues or limitations + - Update usage examples to include new functionality + +5. Document new testing procedures: + - Add details on test files created + - Include test running instructions + - Document test coverage + - Explain testing approach for complex components + +## Documentation Formatting and Structure + +1. Maintain consistent documentation style: + - Use clear headings and sections + - Include code examples where helpful + - Use status indicators (✅, ⚠️, ❌) consistently + - Maintain proper Markdown formatting + +2. Ensure documentation completeness: + - Cover all implemented features + - Include usage examples + - Document API changes or additions + - Include troubleshooting guidance for common issues + +## Guidelines + +- DO NOT CREATE new specification files +- UPDATE existing files in the `specs/` directory +- Maintain consistent documentation style +- Include practical examples where appropriate +- Cross-reference related documentation sections +- Document best practices and lessons learned +- Provide clear status updates on project progress +- Update numerical completion percentages +- Ensure documentation reflects actual implementation + +Provide a summary of documentation updates after completion, including: +1. Files updated +2. Major changes to documentation +3. Updated completion percentages +4. New best practices documented +5. Status of the overall project after this phase \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1e2b006..4466d3d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,5 +3,10 @@ "allow": [ "Bash(wc:*)" ] - } + }, + "enabledMcpjsonServers": [ + "sauci-prod", + "sauci-non-prod" + ], + "enableAllProjectMcpServers": true } diff --git a/.gitignore b/.gitignore index 3492e0a..fc6ca15 100755 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ .pnp .pnp.js +tmp/ # Build outputs dist/ build/ @@ -53,7 +54,7 @@ coverage/ .temp/ .tmp/ *.tmp -nul - -# Opencode -.opencode/ +nul + +# Opencode +.opencode/ diff --git a/apps/admin/Dockerfile.dev b/apps/admin/Dockerfile.dev index 14f4b28..3b98f51 100644 --- a/apps/admin/Dockerfile.dev +++ b/apps/admin/Dockerfile.dev @@ -2,15 +2,24 @@ FROM node:20-alpine WORKDIR /app +# Copy root package files and workspace configs COPY package*.json ./ COPY turbo.json ./ + +# Copy all workspace package.json files COPY apps/admin/package.json ./apps/admin/ +COPY apps/mobile/package.json ./apps/mobile/ +COPY apps/supabase/package.json ./apps/supabase/ +COPY apps/web/package.json ./apps/web/ COPY packages/shared/package.json ./packages/shared/ +# Install all dependencies at root level (workspaces) RUN npm install +# Copy all source code COPY . . +# Build shared package RUN npx turbo run build --filter=@sauci/shared WORKDIR /app/apps/admin diff --git a/apps/admin/docker-compose.yml b/apps/admin/docker-compose.yml index b738c9b..390e77e 100644 --- a/apps/admin/docker-compose.yml +++ b/apps/admin/docker-compose.yml @@ -23,4 +23,6 @@ services: - ./vite.config.ts:/app/apps/admin/vite.config.ts - ./tsconfig.json:/app/apps/admin/tsconfig.json - ../../packages/shared/src:/app/packages/shared/src + # Preserve node_modules from container (don't override with host) + - /app/node_modules restart: unless-stopped diff --git a/apps/admin/src/hooks/useAiConfig.ts b/apps/admin/src/hooks/useAiConfig.ts index 01e6f6f..32737c7 100644 --- a/apps/admin/src/hooks/useAiConfig.ts +++ b/apps/admin/src/hooks/useAiConfig.ts @@ -23,6 +23,16 @@ export interface AiConfig { classifier_enabled: boolean | null; classifier_model: string | null; classifier_prompt: string | null; + heuristics_enabled: boolean | null; + heuristic_min_text_length: number | null; + heuristic_whitelist_max_length: number | null; + heuristic_skip_if_no_alnum: boolean | null; + heuristic_skip_media_without_text: boolean | null; + heuristic_record_reason: boolean | null; + heuristic_use_default_whitelist: boolean | null; + heuristic_use_default_keywords: boolean | null; + heuristic_whitelist: string | null; + heuristic_keyword_triggers: string | null; updated_at: string | null; updated_by: string | null; } diff --git a/apps/admin/src/hooks/useAppConfig.ts b/apps/admin/src/hooks/useAppConfig.ts index a3d2c6a..4f1c407 100644 --- a/apps/admin/src/hooks/useAppConfig.ts +++ b/apps/admin/src/hooks/useAppConfig.ts @@ -4,6 +4,7 @@ import { supabase } from '@/config'; export interface AppConfig { id: string; answer_gap_threshold: number; + daily_response_limit: number; updated_at: string | null; updated_by: string | null; } diff --git a/apps/admin/src/pages/AiSettingsPage.tsx b/apps/admin/src/pages/AiSettingsPage.tsx index 4456e48..e892332 100644 --- a/apps/admin/src/pages/AiSettingsPage.tsx +++ b/apps/admin/src/pages/AiSettingsPage.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import type { KeyboardEvent } from 'react'; import { useAiConfig, AiConfig, CouncilGenerator } from '@/hooks/useAiConfig'; import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext'; import { Button } from '@/components/ui/button'; @@ -10,11 +11,35 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Separator } from '@/components/ui/separator'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Textarea } from '@/components/ui/textarea'; -import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry, Shield } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry, Shield, X } from 'lucide-react'; import { toast } from 'sonner'; import { format } from 'date-fns'; import { ModelCombobox } from '@/components/ModelCombobox'; +type ListField = 'heuristic_whitelist' | 'heuristic_keyword_triggers'; + +const parseListValue = (value?: string | null): string[] => { + if (!value) return []; + return value + .split(/[\n,]+/) + .map((item) => item.trim()) + .filter(Boolean); +}; + +const appendUniqueItem = (items: string[], value: string): string[] => { + const trimmed = value.trim(); + if (!trimmed) return items; + const existing = new Set(items.map((item) => item.toLowerCase())); + if (existing.has(trimmed.toLowerCase())) return items; + return [...items, trimmed]; +}; + +const removeItem = (items: string[], value: string): string[] => { + const target = value.toLowerCase(); + return items.filter((item) => item.toLowerCase() !== target); +}; + export function AiSettingsPage() { const { hasPermission, isSuperAdmin } = useAuth(); const { config, loading, error, updateConfig, refetch } = useAiConfig(); @@ -24,6 +49,8 @@ export function AiSettingsPage() { const [showApiKey, setShowApiKey] = useState(false); const [isSaving, setIsSaving] = useState(false); const [hasChanges, setHasChanges] = useState(false); + const [whitelistInput, setWhitelistInput] = useState(''); + const [keywordInput, setKeywordInput] = useState(''); // Permission check - only super admins or those with MANAGE_AI_CONFIG can access const canManage = isSuperAdmin || hasPermission(PERMISSION_KEYS.MANAGE_AI_CONFIG); @@ -51,6 +78,16 @@ export function AiSettingsPage() { classifier_enabled: config.classifier_enabled ?? true, classifier_model: config.classifier_model || 'openai/gpt-4o', classifier_prompt: config.classifier_prompt || '', + heuristics_enabled: config.heuristics_enabled ?? false, + heuristic_min_text_length: config.heuristic_min_text_length ?? 12, + heuristic_whitelist_max_length: config.heuristic_whitelist_max_length ?? 30, + heuristic_skip_if_no_alnum: config.heuristic_skip_if_no_alnum ?? true, + heuristic_skip_media_without_text: config.heuristic_skip_media_without_text ?? false, + heuristic_record_reason: config.heuristic_record_reason ?? false, + heuristic_use_default_whitelist: config.heuristic_use_default_whitelist ?? true, + heuristic_use_default_keywords: config.heuristic_use_default_keywords ?? true, + heuristic_whitelist: config.heuristic_whitelist || '', + heuristic_keyword_triggers: config.heuristic_keyword_triggers || '', }); setHasChanges(false); } @@ -61,6 +98,46 @@ export function AiSettingsPage() { setHasChanges(true); }; + const whitelistItems = parseListValue(formData.heuristic_whitelist ?? ''); + const keywordItems = parseListValue(formData.heuristic_keyword_triggers ?? ''); + + const updateListField = (field: ListField, items: string[]) => { + handleChange(field, items.length > 0 ? items.join('\n') : ''); + }; + + const handleAddListItem = ( + field: ListField, + value: string, + clearValue: (next: string) => void + ) => { + const current = field === 'heuristic_whitelist' ? whitelistItems : keywordItems; + const nextItems = appendUniqueItem(current, value); + if (nextItems !== current) { + updateListField(field, nextItems); + } + if (value.trim()) { + clearValue(''); + } + }; + + const handleRemoveListItem = (field: ListField, value: string) => { + const current = field === 'heuristic_whitelist' ? whitelistItems : keywordItems; + const nextItems = removeItem(current, value); + updateListField(field, nextItems); + }; + + const handleListKeyDown = ( + event: KeyboardEvent, + field: ListField, + value: string, + clearValue: (next: string) => void + ) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleAddListItem(field, value, clearValue); + } + }; + const handleSave = async () => { setIsSaving(true); try { @@ -99,6 +176,16 @@ export function AiSettingsPage() { classifier_enabled: config.classifier_enabled ?? true, classifier_model: config.classifier_model || 'openai/gpt-4o', classifier_prompt: config.classifier_prompt || '', + heuristics_enabled: config.heuristics_enabled ?? false, + heuristic_min_text_length: config.heuristic_min_text_length ?? 12, + heuristic_whitelist_max_length: config.heuristic_whitelist_max_length ?? 30, + heuristic_skip_if_no_alnum: config.heuristic_skip_if_no_alnum ?? true, + heuristic_skip_media_without_text: config.heuristic_skip_media_without_text ?? false, + heuristic_record_reason: config.heuristic_record_reason ?? false, + heuristic_use_default_whitelist: config.heuristic_use_default_whitelist ?? true, + heuristic_use_default_keywords: config.heuristic_use_default_keywords ?? true, + heuristic_whitelist: config.heuristic_whitelist || '', + heuristic_keyword_triggers: config.heuristic_keyword_triggers || '', }); setHasChanges(false); } @@ -305,6 +392,223 @@ export function AiSettingsPage() { Instructions for the AI classifier.

+ + + +
+
+
+ +

+ Skip AI calls for low-risk messages using simple rules +

+
+ handleChange('heuristics_enabled', checked)} + /> +
+ +
+
+ + + handleChange( + 'heuristic_min_text_length', + e.target.value === '' ? null : Number(e.target.value) + ) + } + /> +

+ Skip AI for shorter messages without media +

+
+
+ + + handleChange( + 'heuristic_whitelist_max_length', + e.target.value === '' ? null : Number(e.target.value) + ) + } + /> +

+ Only apply whitelist to messages below this length +

+
+
+ +
+
+
+ +

+ Treat emoji-only or punctuation-only messages as safe +

+
+ handleChange('heuristic_skip_if_no_alnum', checked)} + /> +
+
+
+ +

+ Skip AI when only media is present +

+
+ handleChange('heuristic_skip_media_without_text', checked)} + /> +
+
+ +
+
+
+ +

+ Include built-in common phrases +

+
+ handleChange('heuristic_use_default_whitelist', checked)} + /> +
+
+
+ +

+ Include built-in safety keywords +

+
+ handleChange('heuristic_use_default_keywords', checked)} + /> +
+
+ +
+
+ +

+ Store skip reason in flag_reason for debugging +

+
+ handleChange('heuristic_record_reason', checked)} + /> +
+ +
+ +
+ setWhitelistInput(e.target.value)} + onKeyDown={(event) => + handleListKeyDown(event, 'heuristic_whitelist', whitelistInput, setWhitelistInput) + } + placeholder="Add phrase" + /> + +
+ {whitelistItems.length > 0 ? ( +
+ {whitelistItems.map((item) => ( + + {item} + + + ))} +
+ ) : ( +

No phrases added yet.

+ )} +

+ Messages matching these phrases are marked safe +

+
+ +
+ +
+ setKeywordInput(e.target.value)} + onKeyDown={(event) => + handleListKeyDown(event, 'heuristic_keyword_triggers', keywordInput, setKeywordInput) + } + placeholder="Add keyword" + /> + +
+ {keywordItems.length > 0 ? ( +
+ {keywordItems.map((item) => ( + + {item} + + + ))} +
+ ) : ( +

No keywords added yet.

+ )} +

+ If any term appears, the message will be sent to AI +

+
+
diff --git a/apps/admin/src/pages/AppSettingsPage.tsx b/apps/admin/src/pages/AppSettingsPage.tsx index f0ac2be..fc9db3c 100644 --- a/apps/admin/src/pages/AppSettingsPage.tsx +++ b/apps/admin/src/pages/AppSettingsPage.tsx @@ -28,6 +28,7 @@ export function AppSettingsPage() { if (config) { setFormData({ answer_gap_threshold: config.answer_gap_threshold ?? 10, + daily_response_limit: config.daily_response_limit ?? 0, }); setHasChanges(false); } @@ -59,6 +60,7 @@ export function AppSettingsPage() { if (config) { setFormData({ answer_gap_threshold: config.answer_gap_threshold ?? 10, + daily_response_limit: config.daily_response_limit ?? 0, }); setHasChanges(false); } @@ -176,6 +178,43 @@ export function AppSettingsPage() { + + {/* Daily Response Limit */} + + + + + Daily Response Limit (Free Users) + + + Limit the number of questions non-premium users can answer per day (UTC 00:00-23:59) + + + +
+ + handleChange('daily_response_limit', parseInt(e.target.value) || 0)} + className="max-w-[200px]" + /> +

+ Maximum number of questions a non-premium user can answer per UTC day. + Premium users (via personal or partner subscription) bypass this limit. +

+

+ Set to 0 to disable daily limits entirely. +

+

+ Limit resets daily at 00:00 UTC. Editing existing answers does not count toward the limit. +

+
+
+
); } diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index b6cc745..5cd416b 100755 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -10,8 +10,9 @@ import { supabase } from "../../src/lib/supabase"; import { isBiometricEnabled } from "../../src/lib/biometricAuth"; import { checkAndRegisterPushToken } from "../../src/lib/notifications"; import { BiometricLockScreen } from "../../src/components/BiometricLockScreen"; +import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal"; import { Events } from "../../src/lib/analytics"; -import type { MatchWithQuestion } from "../../src/types"; +import type { MatchWithQuestion, AnswerType } from "../../src/types"; import type { Database } from "../../src/types/supabase"; type Message = Database["public"]["Tables"]["messages"]["Row"]; @@ -143,8 +144,7 @@ export default function AppLayout() { const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore(); const { fetchEnabledPacks } = usePacksStore(); const { initializeRevenueCat } = useSubscriptionStore(); - const [matchNotification, setMatchNotification] = useState(null); - const matchModalAnim = useRef(new Animated.Value(0)).current; + const [matchNotification, setMatchNotification] = useState(null); const messageToastAnim = useRef(new Animated.Value(-100)).current; const messageTimeoutRef = useRef(null); const [isLocked, setIsLocked] = useState(false); @@ -381,10 +381,42 @@ export default function AppLayout() { Events.milestoneMatch(currentMatchCount + 1); } - // Add to store and show notification + // Add to store addMatch(matchWithQuestion); - setMatchNotification(matchWithQuestion); Events.matchCreated(matchWithQuestion.match_type || "unknown"); + + // Fetch individual responses to show what each person said + const { data: responses } = await supabase + .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, + }); } } ) @@ -509,27 +541,17 @@ export default function AppLayout() { }; }, [lastMessage]); - // Animate match modal when notification changes - useEffect(() => { - if (matchNotification) { - Animated.spring(matchModalAnim, { - toValue: 1, - useNativeDriver: true, - tension: 80, - friction: 10, - }).start(); - } - }, [matchNotification, matchModalAnim]); - const dismissNotification = useCallback(() => { - Animated.timing(matchModalAnim, { - toValue: 0, - duration: 200, - useNativeDriver: true, - }).start(() => { + setMatchNotification(null); + }, []); + + const handleMatchChat = useCallback(() => { + if (matchNotification) { + const matchId = matchNotification.id; setMatchNotification(null); - }); - }, [matchModalAnim]); + router.push(`/(app)/chat/${matchId}`); + } + }, [matchNotification, router]); const dismissMessageToast = useCallback(() => { Animated.timing(messageToastAnim, { @@ -786,53 +808,13 @@ export default function AppLayout() { })} - {/* Match Notification Overlay - Using Animated.View instead of Modal for Android compatibility */} - {matchNotification && ( - - - - - - It's a Match! - - You both agreed on: - - "{matchNotification?.question?.text}" - - - - {matchNotification?.match_type === 'yes_yes' ? 'YES + YES' : - matchNotification?.match_type === 'yes_maybe' ? 'YES + MAYBE' : - 'MAYBE + MAYBE'} - - - - Nice! - - - - )} + {/* Match Notification Modal - Premium styling with response details */} + {/* Message Toast Notification */} {lastMessage && ( @@ -875,72 +857,6 @@ const styles = StyleSheet.create({ alignItems: "center", backgroundColor: colors.background, }, - matchOverlay: { - ...StyleSheet.absoluteFillObject, - justifyContent: 'center', - alignItems: 'center', - zIndex: 9999, - elevation: 9999, - }, - matchOverlayBackground: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0, 0, 0, 0.7)', - }, - modalContent: { - backgroundColor: colors.backgroundLight, - borderRadius: radius.xl, - padding: spacing.xl, - alignItems: 'center', - width: '90%', - maxWidth: 340, - borderWidth: 1, - borderColor: colors.glass.border, - ...shadows.lg, - }, - modalHeader: { - alignItems: 'center', - marginBottom: spacing.lg, - }, - modalTitle: { - ...typography.title1, - color: colors.text, - marginTop: spacing.md, - }, - modalSubtitle: { - ...typography.body, - color: colors.textSecondary, - marginBottom: spacing.sm, - }, - modalQuestion: { - ...typography.headline, - color: colors.text, - textAlign: 'center', - marginBottom: spacing.lg, - }, - matchTypeBadge: { - backgroundColor: colors.primaryLight, - paddingHorizontal: spacing.md, - paddingVertical: spacing.xs, - borderRadius: radius.full, - marginBottom: spacing.lg, - }, - matchTypeText: { - ...typography.caption1, - color: colors.primary, - fontWeight: '600', - }, - dismissButton: { - backgroundColor: colors.primary, - paddingHorizontal: spacing.xl, - paddingVertical: spacing.md, - borderRadius: radius.lg, - minWidth: 120, - }, - dismissButtonText: { - ...typography.headline, - color: colors.text, - textAlign: 'center', - }, messageToast: { position: 'absolute', top: Platform.OS === 'ios' ? 50 : 20, diff --git a/apps/mobile/app/(app)/matches.tsx b/apps/mobile/app/(app)/matches.tsx index 6ec5f4c..daaf0d6 100755 --- a/apps/mobile/app/(app)/matches.tsx +++ b/apps/mobile/app/(app)/matches.tsx @@ -18,6 +18,7 @@ import Animated, { } from "react-native-reanimated"; import { BlurView } from "expo-blur"; import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui"; +import { SwipeableMatchItem } from "../../src/components/matches"; import { useAmbientOrbAnimation } from "../../src/hooks"; import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme"; import { MatchesTutorial } from "../../src/components/tutorials"; @@ -37,7 +38,23 @@ const HEADER_SCROLL_DISTANCE = 100; const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); export default function MatchesScreen() { - const { matches, fetchMatches, markAllAsSeen, isLoading, hasMore, isLoadingMore, totalCount } = useMatchStore(); + const { + matches, + fetchMatches, + markAllAsSeen, + isLoading, + hasMore, + isLoadingMore, + totalCount, + // Archive state and methods + archivedMatches, + showArchived, + toggleShowArchived, + archiveMatch, + unarchiveMatch, + isLoadingArchived, + fetchArchivedMatches, + } = useMatchStore(); const { user } = useAuthStore(); const router = useRouter(); const { width } = useWindowDimensions(); @@ -150,84 +167,97 @@ export default function MatchesScreen() { const isYesYes = item.match_type === "yes_yes"; + const handleArchive = async () => { + if (showArchived) { + await unarchiveMatch(item.id); + } else { + await archiveMatch(item.id); + } + }; + return ( - router.push(`/chat/${item.id}`)} - activeOpacity={0.8} + - - {/* Subtle gradient background */} - - {/* Top silk highlight */} - + router.push(`/chat/${item.id}`)} + activeOpacity={0.8} + > + + {/* Subtle gradient background */} + + {/* Top silk highlight */} + - - {/* Premium icon container */} - - - + + {/* Premium icon container */} + + + - - - {userText} - - {item.question.partner_text && ( - - Partner: {partnerText} + + + {userText} - )} - - - + Partner: {partnerText} + + )} + + - {isYesYes ? "YES + YES" : "YES + MAYBE"} + + {isYesYes ? "YES + YES" : "YES + MAYBE"} + + + + {new Date(item.created_at).toLocaleDateString()} - - {new Date(item.created_at).toLocaleDateString()} - - - - {item.unreadCount > 0 && ( - - + + {item.unreadCount > 0 && ( + + + + )} + + - )} - - - - {/* Premium border */} - - - + {/* Premium border */} + + + + ); }; @@ -296,7 +326,7 @@ export default function MatchesScreen() { item.id} contentContainerStyle={[styles.list, isWideScreen && styles.listWide]} @@ -328,50 +358,122 @@ export default function MatchesScreen() { {/* Decorative separator */} + {/* Filter toggle */} + + showArchived && toggleShowArchived()} + activeOpacity={0.7} + > + + + Active + + + { + if (!showArchived) { + toggleShowArchived(); + } + }} + activeOpacity={0.7} + > + {isLoadingArchived ? ( + + ) : ( + <> + + + Archived + + + )} + + + {/* Count badge */} - - {totalCount ?? matches.length} {(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'} + + + {showArchived + ? `${archivedMatches.length} ARCHIVED` + : `${totalCount ?? matches.length} ${(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'}` + } + } ListEmptyComponent={ - - {/* Description */} - - Answer questions together to discover what you both enjoy. Matches appear when you agree! - - - {/* Feature hints */} - - - - Swipe right for yes - - - - Match when you both agree - - - - Chat about your matches - - - - {/* Bottom teaser */} - Your first match is just a swipe away - - router.push("/(app)/swipe")} - style={{ marginTop: spacing.lg }} + showArchived ? ( + - Start Swiping - - + {/* Archive icon */} + + + + + {/* Description */} + No Archived Matches + + Matches you archive will appear here. Swipe left on any match to archive it. + + + toggleShowArchived()} + style={{ marginTop: spacing.lg }} + > + View Active Matches + + + ) : ( + + {/* Description */} + + Answer questions together to discover what you both enjoy. Matches appear when you agree! + + + {/* Feature hints */} + + + + Swipe right for yes + + + + Match when you both agree + + + + Chat about your matches + + + + {/* Bottom teaser */} + Your first match is just a swipe away + + router.push("/(app)/swipe")} + style={{ marginTop: spacing.lg }} + > + Start Swiping + + + ) } /> @@ -478,6 +580,36 @@ const styles = StyleSheet.create({ color: colors.text, textAlign: 'center', }, + // Filter toggle + filterContainer: { + flexDirection: 'row', + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderRadius: radius.lg, + padding: 4, + marginBottom: spacing.md, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.08)', + }, + filterTab: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.md, + gap: spacing.xs, + }, + filterTabActive: { + backgroundColor: `${ACCENT_RGBA}0.15)`, + }, + filterTabText: { + ...typography.caption1, + color: colors.textTertiary, + fontWeight: '500', + }, + filterTabTextActive: { + color: ACCENT, + fontWeight: '600', + }, countBadgePremium: { flexDirection: 'row', alignItems: 'center', @@ -628,6 +760,22 @@ const styles = StyleSheet.create({ paddingHorizontal: spacing.md, paddingVertical: spacing.xl, }, + emptyIconContainer: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: `${ACCENT_RGBA}0.1)`, + borderWidth: 1, + borderColor: `${ACCENT_RGBA}0.2)`, + justifyContent: 'center', + alignItems: 'center', + marginBottom: spacing.lg, + }, + emptyTitle: { + ...typography.title3, + color: colors.text, + marginBottom: spacing.sm, + }, emptyDescription: { ...typography.body, color: colors.textSecondary, diff --git a/apps/mobile/app/(app)/swipe.tsx b/apps/mobile/app/(app)/swipe.tsx index 6077a83..00362d6 100755 --- a/apps/mobile/app/(app)/swipe.tsx +++ b/apps/mobile/app/(app)/swipe.tsx @@ -1,892 +1,1211 @@ -import { useState, useEffect, useRef, useCallback } from "react"; -import { View, StyleSheet, Text, ActivityIndicator, Platform, TouchableOpacity } from "react-native"; -import { useLocalSearchParams, router, useFocusEffect } from "expo-router"; -import { Ionicons } from "@expo/vector-icons"; -import Animated, { - FadeIn, - FadeInUp, - useSharedValue, - useAnimatedStyle, - withRepeat, - withTiming, - interpolate, - Easing, -} from "react-native-reanimated"; -import { LinearGradient } from "expo-linear-gradient"; -import { supabase } from "../../src/lib/supabase"; -import { usePacksStore, useAuthStore } from "../../src/store"; -import { useAmbientOrbAnimation } from "../../src/hooks"; -import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions"; -import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen"; -import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler"; -// import SwipeCard from "../../src/components/SwipeCard"; -import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // PoC: Premium styling -import { SwipeTutorial } from "../../src/components/tutorials"; -import { QuestionFeedbackModal } from "../../src/components/feedback"; -import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui"; -import { Events } from "../../src/lib/analytics"; -import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme"; - -export default function SwipeScreen() { - const params = useLocalSearchParams(); - // Normalize packId - useLocalSearchParams can return string | string[] - const packId = Array.isArray(params.packId) ? params.packId[0] : params.packId; - const [questions, setQuestions] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [currentIndex, setCurrentIndex] = useState(0); - const [showTutorial, setShowTutorial] = useState(false); - const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null); - const [isBlocked, setIsBlocked] = useState(false); - const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null); - const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore(); - const { partner, couple } = useAuthStore(); - const hasTrackedExhausted = useRef(false); - const fetchRequestId = useRef(0); - - // Ambient orb breathing animations - const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation(); - - // Progress bar shimmer animation - const shimmerPosition = useSharedValue(-1); - - useEffect(() => { - // Progress bar shimmer sweep - 2.5 second cycle - shimmerPosition.value = withRepeat( - withTiming(2, { duration: 2500, easing: Easing.linear }), - -1, - false - ); - }, []); - - const shimmerStyle = useAnimatedStyle(() => ({ - transform: [ - { translateX: interpolate(shimmerPosition.value, [-1, 2], [-60, 220]) }, - ], - })); - - const isFirstMount = useRef(true); - - useEffect(() => { - // Reset state when pack changes - setCurrentIndex(0); - setIsLoading(true); - setIsBlocked(false); - setGapInfo(null); - hasTrackedExhausted.current = false; - - // Use lightweight ensureEnabledPacksLoaded instead of full fetchPacks - ensureEnabledPacksLoaded().then(() => fetchQuestions()); - checkTutorial(); - }, [packId]); - - // Refresh questions when screen gains focus (handles partner answering while away) - useFocusEffect( - useCallback(() => { - // Skip on first mount since useEffect already fetches - if (isFirstMount.current) { - isFirstMount.current = false; - return; - } - // Silently refetch to get updated partner_answered status - fetchQuestions(); - }, [packId]) - ); - - // Check if user is too far ahead of partner (called when questions empty or after swipe) - const checkAnswerGap = async (): Promise => { - if (!couple || !partner) { - setIsBlocked(false); - setGapInfo(null); - return false; - } - - try { - const { data, error } = await supabase.rpc('get_answer_gap_status'); - - if (error) { - console.error('Error checking answer gap:', error); - // Clear blocked state on error so user isn't stuck - setIsBlocked(false); - setGapInfo(null); - return false; - } - - if (data && data.length > 0) { - const result = data[0]; - setIsBlocked(result.is_blocked); - // Only set gap info if threshold is enabled (> 0) - if (result.threshold > 0) { - setGapInfo({ - unanswered: result.unanswered_by_partner, - threshold: result.threshold - }); - } else { - setGapInfo(null); - } - return result.is_blocked; - } - - // No data returned - clear blocked state - setIsBlocked(false); - setGapInfo(null); - return false; - } catch (error) { - console.error('Failed to check answer gap:', error); - // Clear blocked state on error so user isn't stuck - setIsBlocked(false); - setGapInfo(null); - return false; - } - }; - - // Calculate effective total questions (accounting for gap limit) - const effectiveTotal = (() => { - if (!gapInfo || gapInfo.threshold === 0) { - // No gap limit active, show all questions - return questions.length; - } - // remaining = how many more questions until blocked - const remaining = gapInfo.threshold - gapInfo.unanswered; - // effective total = current position + remaining (capped by actual questions) - return Math.min(questions.length, currentIndex + Math.max(0, remaining)); - })(); - - // Track when all questions are exhausted - useEffect(() => { - if (questions.length > 0 && currentIndex >= questions.length && !hasTrackedExhausted.current) { - hasTrackedExhausted.current = true; - Events.allQuestionsExhausted(); - } - }, [currentIndex, questions.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 - useEffect(() => { - if (!packId && enabledPackIds.length > 0 && questions.length > 0) { - const enabledSet = new Set(enabledPackIds); - setQuestions(prev => { - const filtered = prev.filter(q => enabledSet.has(q.pack_id)); - // Adjust currentIndex if it's now beyond the filtered list - if (currentIndex >= filtered.length && filtered.length > 0) { - setCurrentIndex(filtered.length - 1); - } - return filtered; - }); - } - }, [enabledPackIds]); - - - const fetchQuestions = async () => { - // Race protection: increment request ID and track current request - const currentRequestId = ++fetchRequestId.current; - - try { - const [{ data, error }, skippedIds] = await Promise.all([ - supabase.rpc("get_recommended_questions", { - target_pack_id: packId || null - }), - getSkippedQuestionIds() - ]); - - // Race guard: if a newer request started, discard this result - if (currentRequestId !== fetchRequestId.current) { - return; - } - - if (error) { - console.error("Error fetching recommended questions:", error); - throw error; - } - - // Filter out recently skipped questions - const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id)); - - // Always check answer gap when user has partner and not viewing specific pack - // This gives us the gap info for accurate counter display - if (partner && !packId) { - await checkAnswerGap(); - } else { - // Reset blocked state if no partner or viewing specific pack - setIsBlocked(false); - setGapInfo(null); - } - - // Server provides ordering (daily seeded random with priority) - // Only update state if still the current request - if (currentRequestId === fetchRequestId.current) { - setQuestions(filtered); - } - } catch (error) { - if (currentRequestId === fetchRequestId.current) { - console.error(error); - } - } finally { - if (currentRequestId === fetchRequestId.current) { - setIsLoading(false); - } - } - }; - - const handleSwipe = async (direction: "left" | "right" | "up" | "down") => { - const question = questions[currentIndex]; - - setCurrentIndex(prev => prev + 1); - - // Handle skip - don't submit a response, just store it for later - if (direction === "down") { - await skipQuestion(question.id); - Events.questionSkipped(); - return; - } - - const answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe"; - - try { - const { error } = await invokeWithAuthRetry("submit-response", { - body: { - question_id: question.id, - answer, - }, - }); - - if (error) { - console.error("Submit response error:", error); - } else { - Events.questionAnswered(answer, question.pack_id); - // Check if we've hit the answer gap threshold - await checkAnswerGap(); - } - } catch (error) { - console.error("Failed to submit response", error); - } - }; - - if (isLoading) { - return ( - - - - - - ); - } - - // Show "pair with partner" prompt when user doesn't have a partner yet - if (!partner) { - const PAIR_ACCENT = colors.premium.rose; - return ( - - - - {/* Icon */} - - - - - {/* Title section */} - {couple ? "ALMOST THERE" : "CONNECT"} - {couple ? "Waiting" : "Pair Up"} - - - - {/* Status badge */} - - {couple ? "INVITE SENT" : "MADE FOR TWO"} - - - {/* Description */} - - {couple - ? "Share your invite code so they can join you. Once paired, you'll both answer questions and discover what you agree on!" - : "Sauci is made for two! Connect with your partner to start answering questions together and discover what you have in common." - } - - - {/* Feature hints */} - - - - Private and secure - - - - Discover hidden desires - - - - Chat about your matches - - - - {/* Bottom teaser */} - {couple ? "Your partner is just a code away" : "Begin your intimate journey together"} - - router.push("/pairing")} - style={{ marginTop: spacing.lg }} - > - {couple ? "View Invite Code" : "Pair Now"} - - - - - ); - } - - // Show "waiting for partner" message when user is too far ahead - if (isBlocked && gapInfo) { - const WAITING_ACCENT = colors.premium.rose; - return ( - - - - {/* Icon */} - - - - - {/* Title section */} - PATIENCE - Waiting - - - - {/* Status badge */} - - {gapInfo.unanswered} UNANSWERED - - - {/* Description */} - - You're ahead by {gapInfo.unanswered} questions. Give your partner some time to catch up so you can discover matches together. - - - {/* Feature hints */} - - - - Matches happen together - - - - We'll notify your partner - - - - Check back anytime - - - - {/* Bottom teaser */} - Good things come to those who wait - - - Check Again - - - - - ); - } - - // Show "no packs enabled" message when not viewing a specific pack and no packs are enabled - if (!packId && enabledPackIds.length === 0) { - const NO_PACKS_ACCENT = colors.premium.rose; - return ( - - - - {/* Icon */} - - - - - {/* Title section */} - GET STARTED - Choose Packs - - - - {/* Status badge */} - - NO PACKS ENABLED - - - {/* Description */} - - Select the question packs that interest you and your partner. Each pack explores different aspects of your relationship. - - - {/* Feature hints */} - - - - From playful to passionate - - - - Safe space to explore - - - - New packs added regularly - - - - {/* Bottom teaser */} - Your journey of discovery awaits - - router.push("/packs")} - style={{ marginTop: spacing.lg }} - > - Choose Packs - - - - - ); - } - - if (currentIndex >= questions.length) { - const CAUGHT_UP_ACCENT = colors.premium.rose; - return ( - - - - {/* Icon */} - - - - - {/* Title section */} - COMPLETE - All Caught Up - - - - {/* Status badge */} - - YOU'RE AHEAD - - - {/* Description */} - - You've answered all available questions. New questions are added regularly, or explore different packs. - - - {/* Feature hints */} - - - - New questions weekly - - - - Try different packs - - - - Chat about your matches - - - - {/* Bottom teaser */} - More ways to connect are on the way - - - Refresh Questions - - - - - ); - } - - return ( - - {/* Ambient Orbs - Premium gold/rose */} - - - - - - - - - {/* Header */} - - - {/* Premium label */} - EXPLORE - - {currentIndex + 1} of {effectiveTotal || questions.length} - - - - {/* Shimmer sweep */} - - - - - - - - - {/* Card Stack */} - - {/* Third card (deepest) */} - {questions[currentIndex + 2] && ( - - )} - - {/* Second card */} - {questions[currentIndex + 1] && ( - - )} - - {/* Active card */} - setFeedbackQuestion({ - id: questions[currentIndex].id, - text: questions[currentIndex].text - })} - /> - - - {/* Hint - Premium styling */} - - Swipe or tap to answer - - router.push({ pathname: "/my-answers", params: { returnTo: "/(app)/swipe" } })} - style={styles.editLinkButton} - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - > - Edit previous answers - - - - {/* Bottom spacing for tab bar */} - - - - {/* Tutorial Overlay */} - {showTutorial && ( - - )} - - {/* Question Feedback Modal */} - setFeedbackQuestion(null)} - questionId={feedbackQuestion?.id || ''} - questionText={feedbackQuestion?.text || ''} - /> - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - centerContainer: { - flex: 1, - justifyContent: "center", - alignItems: "center", - }, - // Ambient orbs - more visible - ambientOrb: { - position: 'absolute', - width: 300, - height: 300, - borderRadius: 150, - }, - orbTopRight: { - top: 60, - right: -40, - }, - orbBottomLeft: { - bottom: 180, - left: -40, - }, - orbGradient: { - width: '100%', - height: '100%', - borderRadius: 150, - }, - header: { - paddingTop: 60, - paddingHorizontal: spacing.lg, - paddingBottom: spacing.md, - }, - progressContainer: { - alignItems: "center", - }, - progressLabel: { - ...typography.caption2, - fontWeight: '600', - letterSpacing: 3, - color: colors.premium.gold, - marginBottom: spacing.xs, - }, - progressText: { - ...typography.subhead, - color: colors.textSecondary, - marginBottom: spacing.sm, - }, - progressBar: { - width: 160, - height: 6, - backgroundColor: colors.glass.background, - borderRadius: 3, - overflow: "hidden", - }, - progressBarPremium: { - width: 140, - height: 4, - borderRadius: 2, - backgroundColor: 'rgba(212, 175, 55, 0.1)', - borderWidth: 1, - borderColor: 'rgba(212, 175, 55, 0.15)', - }, - progressFill: { - height: "100%", - borderRadius: 4, - overflow: "hidden", - }, - progressFillPremium: { - backgroundColor: 'rgba(212, 175, 55, 0.5)', - borderRadius: 2, - }, - progressGradient: { - flex: 1, - borderRadius: 4, - }, - progressShimmer: { - position: 'absolute', - width: 60, - height: '100%', - top: 0, - }, - cardContainer: { - flex: 1, - justifyContent: "center", - alignItems: "center", - }, - // Card stack - Premium styling - stackCard: { - position: "absolute", - width: "85%", - height: 500, - backgroundColor: '#0d0d1a', - borderRadius: radius.xl, - borderWidth: 1, - borderColor: 'rgba(212, 175, 55, 0.15)', - }, - stackCardSecond: { - transform: [{ scale: 0.95 }, { translateY: 12 }], - opacity: 0.4, - ...shadows.md, - }, - stackCardThird: { - transform: [{ scale: 0.90 }, { translateY: 24 }], - opacity: 0.2, - ...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: { - height: Platform.OS === 'ios' ? 100 : 80, - }, - // Premium "waiting for partner" styles - waitingContent: { - width: '100%', - alignItems: 'center', - paddingHorizontal: spacing.md, - }, - waitingIconContainer: { - width: 72, - height: 72, - borderRadius: 36, - backgroundColor: 'rgba(232, 164, 174, 0.1)', - borderWidth: 1, - borderColor: 'rgba(232, 164, 174, 0.2)', - justifyContent: 'center', - alignItems: 'center', - marginBottom: spacing.xl, - }, - waitingLabel: { - ...typography.caption1, - fontWeight: '600', - letterSpacing: 3, - color: colors.premium.rose, - textAlign: 'center', - marginBottom: spacing.xs, - }, - waitingTitle: { - ...typography.largeTitle, - color: colors.text, - textAlign: 'center', - }, - waitingBadge: { - backgroundColor: 'rgba(232, 164, 174, 0.1)', - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - borderRadius: radius.full, - borderWidth: 1, - borderColor: 'rgba(232, 164, 174, 0.2)', - marginBottom: spacing.xl, - }, - waitingBadgeText: { - ...typography.caption2, - fontWeight: '600', - letterSpacing: 2, - color: colors.premium.rose, - }, - waitingDescription: { - ...typography.body, - color: colors.textSecondary, - textAlign: 'center', - marginBottom: spacing.xl, - paddingHorizontal: spacing.md, - }, - waitingFeatures: { - marginBottom: spacing.xl, - }, - waitingFeatureItem: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - marginBottom: spacing.sm, - }, - waitingFeatureText: { - ...typography.subhead, - color: colors.text, - marginLeft: spacing.sm, - }, - waitingTeaser: { - ...typography.footnote, - fontStyle: 'italic', - color: colors.textTertiary, - textAlign: 'center', - }, - // Caught up specific styles - caughtUpIconContainer: { - width: 72, - height: 72, - borderRadius: 36, - backgroundColor: 'rgba(232, 164, 174, 0.1)', - borderWidth: 1, - borderColor: 'rgba(232, 164, 174, 0.2)', - justifyContent: 'center', - alignItems: 'center', - marginBottom: spacing.xl, - }, - caughtUpLabel: { - ...typography.caption1, - fontWeight: '600', - letterSpacing: 3, - color: colors.premium.rose, - textAlign: 'center', - marginBottom: spacing.xs, - }, -}); +import { useState, useEffect, useRef, useCallback } from "react"; +import { View, StyleSheet, Text, ActivityIndicator, Platform, TouchableOpacity } from "react-native"; +import { useLocalSearchParams, router, useFocusEffect } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; +import Animated, { + FadeIn, + FadeInUp, + useSharedValue, + useAnimatedStyle, + withRepeat, + withTiming, + interpolate, + Easing, +} from "react-native-reanimated"; +import { LinearGradient } from "expo-linear-gradient"; +import { supabase } from "../../src/lib/supabase"; +import { usePacksStore, useAuthStore } from "../../src/store"; +import { useAmbientOrbAnimation } from "../../src/hooks"; +import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions"; +import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen"; +import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler"; +// import SwipeCard from "../../src/components/SwipeCard"; +import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // PoC: Premium styling +import { SwipeTutorial } from "../../src/components/tutorials"; +import { QuestionFeedbackModal } from "../../src/components/feedback"; +import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui"; +import { Paywall } from "../../src/components/paywall"; +import { Events } from "../../src/lib/analytics"; +import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme"; + +interface DailyLimitInfo { + responses_today: number; + limit_value: number; + remaining: number; + reset_at: string; + is_blocked: boolean; +} + +export default function SwipeScreen() { + const params = useLocalSearchParams(); + // Normalize packId - useLocalSearchParams can return string | string[] + const packId = Array.isArray(params.packId) ? params.packId[0] : params.packId; + const [questions, setQuestions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [currentIndex, setCurrentIndex] = useState(0); + const [showTutorial, setShowTutorial] = useState(false); + const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null); + const [isBlocked, setIsBlocked] = useState(false); + const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null); + const [dailyLimitInfo, setDailyLimitInfo] = useState(null); + const [showPaywall, setShowPaywall] = useState(false); + const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore(); + const { partner, couple } = useAuthStore(); + const hasTrackedExhausted = useRef(false); + const fetchRequestId = useRef(0); + + // Ambient orb breathing animations + const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation(); + + // Progress bar shimmer animation + const shimmerPosition = useSharedValue(-1); + + // Countdown for daily limit reset + const [countdown, setCountdown] = useState(""); + + useEffect(() => { + if (!dailyLimitInfo?.is_blocked || !dailyLimitInfo.reset_at) { + setCountdown(""); + return; + } + + const updateCountdown = () => { + const now = new Date(); + const reset = new Date(dailyLimitInfo.reset_at); + const diff = reset.getTime() - now.getTime(); + + if (diff <= 0) { + setCountdown("00:00:00"); + // Refresh questions when timer expires + fetchQuestions(); + return; + } + + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + setCountdown(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`); + }; + + updateCountdown(); + const interval = setInterval(updateCountdown, 1000); + + return () => clearInterval(interval); + }, [dailyLimitInfo?.is_blocked, dailyLimitInfo?.reset_at]); + + useEffect(() => { + // Progress bar shimmer sweep - 2.5 second cycle + shimmerPosition.value = withRepeat( + withTiming(2, { duration: 2500, easing: Easing.linear }), + -1, + false + ); + }, []); + + const shimmerStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: interpolate(shimmerPosition.value, [-1, 2], [-60, 220]) }, + ], + })); + + const isFirstMount = useRef(true); + + useEffect(() => { + // Reset state when pack changes + setCurrentIndex(0); + setIsLoading(true); + setIsBlocked(false); + setGapInfo(null); + hasTrackedExhausted.current = false; + + // Use lightweight ensureEnabledPacksLoaded instead of full fetchPacks + ensureEnabledPacksLoaded().then(() => fetchQuestions()); + checkTutorial(); + }, [packId]); + + // Refresh questions when screen gains focus (handles partner answering while away) + useFocusEffect( + useCallback(() => { + // Skip on first mount since useEffect already fetches + if (isFirstMount.current) { + isFirstMount.current = false; + return; + } + // Silently refetch to get updated partner_answered status + fetchQuestions(); + }, [packId]) + ); + + // Check if user is too far ahead of partner (called when questions empty or after swipe) + const checkAnswerGap = async (): Promise => { + if (!couple || !partner) { + setIsBlocked(false); + setGapInfo(null); + return false; + } + + try { + const { data, error } = await supabase.rpc('get_answer_gap_status'); + + if (error) { + console.error('Error checking answer gap:', error); + // Clear blocked state on error so user isn't stuck + setIsBlocked(false); + setGapInfo(null); + return false; + } + + if (data && data.length > 0) { + const result = data[0]; + setIsBlocked(result.is_blocked); + // Only set gap info if threshold is enabled (> 0) + if (result.threshold > 0) { + setGapInfo({ + unanswered: result.unanswered_by_partner, + threshold: result.threshold + }); + } else { + setGapInfo(null); + } + return result.is_blocked; + } + + // No data returned - clear blocked state + setIsBlocked(false); + setGapInfo(null); + return false; + } catch (error) { + console.error('Failed to check answer gap:', error); + // Clear blocked state on error so user isn't stuck + setIsBlocked(false); + setGapInfo(null); + return false; + } + }; + + // Check if user has reached daily response limit + const checkDailyLimit = async (): Promise => { + try { + const { data, error } = await supabase.rpc('get_daily_response_limit_status'); + + if (error) { + console.error('Error checking daily limit:', error); + setDailyLimitInfo(null); + return false; + } + + if (data && data.length > 0) { + const result = data[0]; + setDailyLimitInfo(result); + return result.is_blocked; + } + + setDailyLimitInfo(null); + return false; + } catch (error) { + console.error('Failed to check daily limit:', error); + setDailyLimitInfo(null); + return false; + } + }; + + // Calculate effective total questions (accounting for gap limit AND daily limit) + const effectiveTotal = (() => { + let total = questions.length; + + // Apply gap limit if active + if (gapInfo && gapInfo.threshold > 0) { + const remaining = gapInfo.threshold - gapInfo.unanswered; + total = Math.min(total, currentIndex + Math.max(0, remaining)); + } + + // Apply daily limit if active + if (dailyLimitInfo && dailyLimitInfo.limit_value > 0 && !dailyLimitInfo.is_blocked) { + total = Math.min(total, currentIndex + dailyLimitInfo.remaining); + } + + return total; + })(); + + // Track when all questions are exhausted + useEffect(() => { + if (questions.length > 0 && currentIndex >= questions.length && !hasTrackedExhausted.current) { + hasTrackedExhausted.current = true; + Events.allQuestionsExhausted(); + } + }, [currentIndex, questions.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 + useEffect(() => { + if (!packId && enabledPackIds.length > 0 && questions.length > 0) { + const enabledSet = new Set(enabledPackIds); + setQuestions(prev => { + const filtered = prev.filter(q => enabledSet.has(q.pack_id)); + // Adjust currentIndex if it's now beyond the filtered list + if (currentIndex >= filtered.length && filtered.length > 0) { + setCurrentIndex(filtered.length - 1); + } + return filtered; + }); + } + }, [enabledPackIds]); + + + const fetchQuestions = async () => { + // Race protection: increment request ID and track current request + const currentRequestId = ++fetchRequestId.current; + + try { + const [{ data, error }, skippedIds] = await Promise.all([ + supabase.rpc("get_recommended_questions", { + target_pack_id: packId || null + }), + getSkippedQuestionIds() + ]); + + // Race guard: if a newer request started, discard this result + if (currentRequestId !== fetchRequestId.current) { + return; + } + + if (error) { + console.error("Error fetching recommended questions:", error); + throw error; + } + + // Filter out recently skipped questions + const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id)); + + // Always check answer gap and daily limit when user has partner and not viewing specific pack + if (partner && !packId) { + await Promise.all([checkAnswerGap(), checkDailyLimit()]); + } else { + // Reset blocked state if no partner or viewing specific pack + setIsBlocked(false); + setGapInfo(null); + setDailyLimitInfo(null); + } + + // Server provides ordering (daily seeded random with priority) + // Only update state if still the current request + if (currentRequestId === fetchRequestId.current) { + setQuestions(filtered); + } + } catch (error) { + if (currentRequestId === fetchRequestId.current) { + console.error(error); + } + } finally { + if (currentRequestId === fetchRequestId.current) { + setIsLoading(false); + } + } + }; + + const handleSwipe = async (direction: "left" | "right" | "up" | "down") => { + const question = questions[currentIndex]; + + setCurrentIndex(prev => prev + 1); + + // Handle skip - don't submit a response, just store it for later + if (direction === "down") { + await skipQuestion(question.id); + Events.questionSkipped(); + return; + } + + const answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe"; + + try { + const { error } = await invokeWithAuthRetry("submit-response", { + body: { + question_id: question.id, + answer, + }, + }); + + if (error) { + console.error("Submit response error:", error); + } else { + Events.questionAnswered(answer, question.pack_id); + + // Increment local daily count if we have limit info + if (dailyLimitInfo && dailyLimitInfo.limit_value > 0) { + setDailyLimitInfo(prev => prev ? { + ...prev, + responses_today: prev.responses_today + 1, + remaining: Math.max(0, prev.remaining - 1), + is_blocked: prev.responses_today + 1 >= prev.limit_value + } : null); + } + + // Check both gap and daily limit + await Promise.all([checkAnswerGap(), checkDailyLimit()]); + } + } catch (error) { + console.error("Failed to submit response", error); + } + }; + + if (isLoading) { + return ( + + + + + + ); + } + + // Show "pair with partner" prompt when user doesn't have a partner yet + if (!partner) { + const PAIR_ACCENT = colors.premium.rose; + return ( + + + + {/* Icon */} + + + + + {/* Title section */} + {couple ? "ALMOST THERE" : "CONNECT"} + {couple ? "Waiting" : "Pair Up"} + + + + {/* Status badge */} + + {couple ? "INVITE SENT" : "MADE FOR TWO"} + + + {/* Description */} + + {couple + ? "Share your invite code so they can join you. Once paired, you'll both answer questions and discover what you agree on!" + : "Sauci is made for two! Connect with your partner to start answering questions together and discover what you have in common." + } + + + {/* Feature hints */} + + + + Private and secure + + + + Discover hidden desires + + + + Chat about your matches + + + + {/* Bottom teaser */} + {couple ? "Your partner is just a code away" : "Begin your intimate journey together"} + + router.push("/pairing")} + style={{ marginTop: spacing.lg }} + > + {couple ? "View Invite Code" : "Pair Now"} + + + + + ); + } + + // Show "daily limit reached" screen when user has exhausted their daily quota + if (dailyLimitInfo?.is_blocked) { + const LIMIT_ACCENT = colors.premium.gold; + return ( + + {/* Ambient Orbs - Gold/warm glow */} + + + + + + + + + + {/* Icon with glow effect */} + + + + + + + + {/* Title section */} + DAILY LIMIT REACHED + Take a Breather + + + + {/* Countdown timer - prominent display */} + + REFRESHES IN + + + {countdown} + + + + {/* Description */} + + You've answered {dailyLimitInfo.limit_value} questions today!{'\n'} + Let the anticipation build while you wait. + + + {/* Feature hints - vertical list */} + + + + Chat about your matches + + + + Fresh questions tomorrow + + + + Go unlimited with Premium + + + + {/* Premium upsell button */} + Want to keep exploring? + setShowPaywall(true)} + style={styles.premiumButton} + activeOpacity={0.85} + > + + + + Unlock Unlimited + + + + + + {/* Paywall Modal */} + setShowPaywall(false)} + onSuccess={() => { + setShowPaywall(false); + // Refresh to clear limit status + fetchQuestions(); + }} + /> + + ); + } + + // Show "waiting for partner" message when user is too far ahead + if (isBlocked && gapInfo) { + const WAITING_ACCENT = colors.premium.rose; + return ( + + + + {/* Icon */} + + + + + {/* Title section */} + PATIENCE + Waiting + + + + {/* Status badge */} + + {gapInfo.unanswered} UNANSWERED + + + {/* Description */} + + You're ahead by {gapInfo.unanswered} questions. Give your partner some time to catch up so you can discover matches together. + + + {/* Feature hints */} + + + + Matches happen together + + + + We'll notify your partner + + + + Check back anytime + + + + {/* Bottom teaser */} + Good things come to those who wait + + + Check Again + + + + + ); + } + + // Show "no packs enabled" message when not viewing a specific pack and no packs are enabled + if (!packId && enabledPackIds.length === 0) { + const NO_PACKS_ACCENT = colors.premium.rose; + return ( + + + + {/* Icon */} + + + + + {/* Title section */} + GET STARTED + Choose Packs + + + + {/* Status badge */} + + NO PACKS ENABLED + + + {/* Description */} + + Select the question packs that interest you and your partner. Each pack explores different aspects of your relationship. + + + {/* Feature hints */} + + + + From playful to passionate + + + + Safe space to explore + + + + New packs added regularly + + + + {/* Bottom teaser */} + Your journey of discovery awaits + + router.push("/packs")} + style={{ marginTop: spacing.lg }} + > + Choose Packs + + + + + ); + } + + if (currentIndex >= questions.length) { + const CAUGHT_UP_ACCENT = colors.premium.rose; + return ( + + + + {/* Icon */} + + + + + {/* Title section */} + COMPLETE + All Caught Up + + + + {/* Status badge */} + + YOU'RE AHEAD + + + {/* Description */} + + You've answered all available questions. New questions are added regularly, or explore different packs. + + + {/* Feature hints */} + + + + New questions weekly + + + + Try different packs + + + + Chat about your matches + + + + {/* Bottom teaser */} + More ways to connect are on the way + + + Refresh Questions + + + + + ); + } + + return ( + + {/* Ambient Orbs - Premium gold/rose */} + + + + + + + + + {/* Header */} + + + {/* Premium label */} + EXPLORE + + {currentIndex + 1} of {effectiveTotal || questions.length} + + + + {/* Shimmer sweep */} + + + + + + + + + {/* Card Stack */} + + {/* Third card (deepest) */} + {questions[currentIndex + 2] && ( + + )} + + {/* Second card */} + {questions[currentIndex + 1] && ( + + )} + + {/* Active card */} + setFeedbackQuestion({ + id: questions[currentIndex].id, + text: questions[currentIndex].text + })} + /> + + + {/* Hint - Premium styling */} + + Swipe or tap to answer + + router.push({ pathname: "/my-answers", params: { returnTo: "/(app)/swipe" } })} + style={styles.editLinkButton} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + Edit previous answers + + + + {/* Bottom spacing for tab bar */} + + + + {/* Tutorial Overlay */} + {showTutorial && ( + + )} + + {/* Question Feedback Modal */} + setFeedbackQuestion(null)} + questionId={feedbackQuestion?.id || ''} + questionText={feedbackQuestion?.text || ''} + /> + + {/* Paywall Modal */} + setShowPaywall(false)} + onSuccess={() => { + setShowPaywall(false); + // Refresh to clear limit status + fetchQuestions(); + }} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + centerContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + // Ambient orbs - more visible + ambientOrb: { + position: 'absolute', + width: 300, + height: 300, + borderRadius: 150, + }, + orbTopRight: { + top: 60, + right: -40, + }, + orbBottomLeft: { + bottom: 180, + left: -40, + }, + orbGradient: { + width: '100%', + height: '100%', + borderRadius: 150, + }, + header: { + paddingTop: 60, + paddingHorizontal: spacing.lg, + paddingBottom: spacing.md, + }, + progressContainer: { + alignItems: "center", + }, + progressLabel: { + ...typography.caption2, + fontWeight: '600', + letterSpacing: 3, + color: colors.premium.gold, + marginBottom: spacing.xs, + }, + progressText: { + ...typography.subhead, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, + progressBar: { + width: 160, + height: 6, + backgroundColor: colors.glass.background, + borderRadius: 3, + overflow: "hidden", + }, + progressBarPremium: { + width: 140, + height: 4, + borderRadius: 2, + backgroundColor: 'rgba(212, 175, 55, 0.1)', + borderWidth: 1, + borderColor: 'rgba(212, 175, 55, 0.15)', + }, + progressFill: { + height: "100%", + borderRadius: 4, + overflow: "hidden", + }, + progressFillPremium: { + backgroundColor: 'rgba(212, 175, 55, 0.5)', + borderRadius: 2, + }, + progressGradient: { + flex: 1, + borderRadius: 4, + }, + progressShimmer: { + position: 'absolute', + width: 60, + height: '100%', + top: 0, + }, + cardContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + // Card stack - Premium styling + stackCard: { + position: "absolute", + width: "85%", + height: 500, + backgroundColor: '#0d0d1a', + borderRadius: radius.xl, + borderWidth: 1, + borderColor: 'rgba(212, 175, 55, 0.15)', + }, + stackCardSecond: { + transform: [{ scale: 0.95 }, { translateY: 12 }], + opacity: 0.4, + ...shadows.md, + }, + stackCardThird: { + transform: [{ scale: 0.90 }, { translateY: 24 }], + opacity: 0.2, + ...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: { + height: Platform.OS === 'ios' ? 100 : 80, + }, + // Premium "waiting for partner" styles + waitingContent: { + width: '100%', + alignItems: 'center', + paddingHorizontal: spacing.md, + }, + waitingIconContainer: { + width: 72, + height: 72, + borderRadius: 36, + backgroundColor: 'rgba(232, 164, 174, 0.1)', + borderWidth: 1, + borderColor: 'rgba(232, 164, 174, 0.2)', + justifyContent: 'center', + alignItems: 'center', + marginBottom: spacing.xl, + }, + waitingLabel: { + ...typography.caption1, + fontWeight: '600', + letterSpacing: 3, + color: colors.premium.rose, + textAlign: 'center', + marginBottom: spacing.xs, + }, + waitingTitle: { + ...typography.largeTitle, + color: colors.text, + textAlign: 'center', + }, + waitingBadge: { + backgroundColor: 'rgba(232, 164, 174, 0.1)', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radius.full, + borderWidth: 1, + borderColor: 'rgba(232, 164, 174, 0.2)', + marginBottom: spacing.xl, + }, + waitingBadgeText: { + ...typography.caption2, + fontWeight: '600', + letterSpacing: 2, + color: colors.premium.rose, + }, + waitingDescription: { + ...typography.body, + color: colors.textSecondary, + textAlign: 'center', + marginBottom: spacing.xl, + paddingHorizontal: spacing.md, + }, + waitingFeatures: { + marginBottom: spacing.xl, + }, + waitingFeatureItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.sm, + }, + waitingFeatureText: { + ...typography.subhead, + color: colors.text, + marginLeft: spacing.sm, + }, + waitingTeaser: { + ...typography.footnote, + fontStyle: 'italic', + color: colors.textTertiary, + textAlign: 'center', + }, + // Caught up specific styles + caughtUpIconContainer: { + width: 72, + height: 72, + borderRadius: 36, + backgroundColor: 'rgba(232, 164, 174, 0.1)', + borderWidth: 1, + borderColor: 'rgba(232, 164, 174, 0.2)', + justifyContent: 'center', + alignItems: 'center', + marginBottom: spacing.xl, + }, + caughtUpLabel: { + ...typography.caption1, + fontWeight: '600', + letterSpacing: 3, + color: colors.premium.rose, + textAlign: 'center', + marginBottom: spacing.xs, + }, + // Daily limit screen styles + dailyLimitOrbTop: { + top: 80, + right: -60, + }, + dailyLimitOrbBottom: { + bottom: 200, + left: -80, + }, + dailyLimitIconWrapper: { + position: 'relative', + marginBottom: spacing.xl, + }, + dailyLimitIconGlow: { + position: 'absolute', + width: 120, + height: 120, + borderRadius: 60, + backgroundColor: 'rgba(212, 175, 55, 0.15)', + top: -24, + left: -24, + }, + dailyLimitIconContainer: { + width: 72, + height: 72, + borderRadius: 36, + backgroundColor: 'rgba(212, 175, 55, 0.1)', + borderWidth: 1.5, + borderColor: 'rgba(212, 175, 55, 0.3)', + justifyContent: 'center', + alignItems: 'center', + }, + dailyLimitLabel: { + ...typography.caption2, + fontWeight: '600', + letterSpacing: 2, + color: colors.premium.gold, + textAlign: 'center', + marginBottom: spacing.xs, + }, + dailyLimitTitle: { + ...typography.largeTitle, + color: colors.text, + textAlign: 'center', + }, + countdownContainer: { + alignItems: 'center', + marginBottom: spacing.xl, + }, + countdownLabel: { + ...typography.caption2, + letterSpacing: 1.5, + color: colors.textTertiary, + marginBottom: spacing.sm, + }, + countdownBadge: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(212, 175, 55, 0.1)', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: 'rgba(212, 175, 55, 0.25)', + gap: spacing.sm, + }, + countdownText: { + ...typography.title2, + fontWeight: '700', + color: colors.premium.gold, + fontVariant: ['tabular-nums'], + letterSpacing: 2, + }, + dailyLimitDescription: { + ...typography.body, + color: colors.textSecondary, + textAlign: 'center', + marginBottom: spacing.lg, + lineHeight: 24, + paddingHorizontal: spacing.md, + }, + premiumUpsellText: { + ...typography.footnote, + fontStyle: 'italic', + color: colors.textTertiary, + textAlign: 'center', + marginBottom: spacing.md, + }, + premiumButton: { + position: 'relative', + borderRadius: radius.lg, + overflow: 'hidden', + ...shadows.md, + }, + premiumButtonGradient: { + ...StyleSheet.absoluteFillObject, + }, + premiumButtonContent: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + gap: spacing.sm, + }, + premiumButtonText: { + ...typography.body, + fontWeight: '600', + color: '#000', + }, +}); diff --git a/apps/mobile/src/__tests__/components/GlassButton.test.tsx b/apps/mobile/src/__tests__/components/GlassButton.test.tsx new file mode 100644 index 0000000..e5f8f67 --- /dev/null +++ b/apps/mobile/src/__tests__/components/GlassButton.test.tsx @@ -0,0 +1,284 @@ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import { Text, View, Platform } from 'react-native'; +import { GlassButton } from '@/components/ui/GlassButton'; + +// Store the mock for expo-haptics so we can check calls +const mockImpactAsync = jest.fn(); + +// Mock expo-haptics - must happen before import +jest.mock('expo-haptics', () => ({ + impactAsync: mockImpactAsync, + ImpactFeedbackStyle: { + Light: 'light', + }, +})); + +describe('GlassButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders children text correctly', () => { + const { getByText } = render( + Click Me + ); + + expect(getByText('Click Me')).toBeTruthy(); + }); + + it('renders with primary variant by default', () => { + const { getByText } = render( + Primary Button + ); + + const text = getByText('Primary Button'); + expect(text).toBeTruthy(); + }); + + it('renders secondary variant', () => { + const { getByText } = render( + Secondary Button + ); + + expect(getByText('Secondary Button')).toBeTruthy(); + }); + + it('renders ghost variant', () => { + const { getByText } = render( + Ghost Button + ); + + expect(getByText('Ghost Button')).toBeTruthy(); + }); + + it('renders danger variant', () => { + const { getByText } = render( + Danger Button + ); + + expect(getByText('Danger Button')).toBeTruthy(); + }); + + it('renders with icon when provided', () => { + const TestIcon = () => Icon; + const { getByTestId, getByText } = render( + }>With Icon + ); + + expect(getByTestId('test-icon')).toBeTruthy(); + expect(getByText('With Icon')).toBeTruthy(); + }); + + it('renders loading indicator when loading is true', () => { + const { queryByText, UNSAFE_getByType } = render( + Loading Button + ); + + // When loading, text should not be visible + expect(queryByText('Loading Button')).toBeNull(); + + // Activity indicator should be present + const ActivityIndicator = require('react-native').ActivityIndicator; + expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy(); + }); + + it('renders with different sizes', () => { + const { rerender, getByText } = render( + Small + ); + expect(getByText('Small')).toBeTruthy(); + + rerender(Medium); + expect(getByText('Medium')).toBeTruthy(); + + rerender(Large); + expect(getByText('Large')).toBeTruthy(); + }); + }); + + describe('press handling', () => { + it('calls onPress when pressed', async () => { + const onPress = jest.fn(); + const { getByText } = render( + Press Me + ); + + fireEvent.press(getByText('Press Me')); + + await waitFor(() => { + expect(onPress).toHaveBeenCalledTimes(1); + }); + }); + + it('does not call onPress when disabled', () => { + const onPress = jest.fn(); + const { getByText } = render( + + Disabled Button + + ); + + fireEvent.press(getByText('Disabled Button')); + + expect(onPress).not.toHaveBeenCalled(); + }); + + it('does not call onPress when loading', () => { + const onPress = jest.fn(); + const { UNSAFE_getByType } = render( + + Loading Button + + ); + + const ActivityIndicator = require('react-native').ActivityIndicator; + const indicator = UNSAFE_getByType(ActivityIndicator); + + // Try to press the parent pressable + // Since loading hides the text, we can't press by text + // The component should still be non-pressable + expect(onPress).not.toHaveBeenCalled(); + }); + + // Note: Haptic tests use haptic={false} because the component uses dynamic import + // which doesn't work properly in Jest without --experimental-vm-modules + it('handles button with haptic prop', () => { + // Verify the component renders with haptic prop + // Actual haptic functionality cannot be tested due to dynamic import limitations + const { getByText } = render( + Haptic Button + ); + + expect(getByText('Haptic Button')).toBeTruthy(); + }); + + it('calls onPress when haptic is false', async () => { + const onPress = jest.fn(); + const { getByText } = render( + + No Haptic + + ); + + fireEvent.press(getByText('No Haptic')); + + await waitFor(() => { + expect(onPress).toHaveBeenCalled(); + }); + }); + }); + + describe('styling', () => { + it('applies fullWidth style when fullWidth is true', () => { + const { getByText } = render( + Full Width Button + ); + + const button = getByText('Full Width Button'); + expect(button).toBeTruthy(); + // The fullWidth style is applied to parent, which sets width: '100%' + }); + + it('applies custom style prop', () => { + const customStyle = { marginTop: 20 }; + const { getByText } = render( + Styled Button + ); + + expect(getByText('Styled Button')).toBeTruthy(); + }); + + it('applies custom textStyle prop', () => { + const customTextStyle = { fontWeight: 'bold' as const }; + const { getByText } = render( + Styled Text + ); + + expect(getByText('Styled Text')).toBeTruthy(); + }); + + it('applies disabled opacity when disabled', () => { + const { getByText } = render( + Disabled Button + ); + + // The disabled style reduces opacity to 0.5 + expect(getByText('Disabled Button')).toBeTruthy(); + }); + }); + + describe('press in/out animations', () => { + it('handles press in event', () => { + const { getByText } = render( + Animated Button + ); + + const button = getByText('Animated Button'); + + // Trigger press in - this should work without errors + fireEvent(button, 'pressIn'); + expect(button).toBeTruthy(); + }); + + it('handles press out event', () => { + const { getByText } = render( + Animated Button + ); + + const button = getByText('Animated Button'); + + // Trigger press in and out + fireEvent(button, 'pressIn'); + fireEvent(button, 'pressOut'); + expect(button).toBeTruthy(); + }); + }); + + describe('default props', () => { + it('has default variant of primary', () => { + const { getByText } = render(Default); + expect(getByText('Default')).toBeTruthy(); + }); + + it('has default size of md', () => { + const { getByText } = render(Default Size); + expect(getByText('Default Size')).toBeTruthy(); + }); + + it('has haptic enabled by default', () => { + // The component has haptic={true} by default + // We can't test the actual haptic call due to dynamic import limitations + // but we verify the prop default works + const { getByText } = render(Default Haptic); + expect(getByText('Default Haptic')).toBeTruthy(); + }); + + it('has disabled false by default', async () => { + const onPress = jest.fn(); + const { getByText } = render( + Not Disabled + ); + + fireEvent.press(getByText('Not Disabled')); + + await waitFor(() => { + expect(onPress).toHaveBeenCalled(); + }); + }); + + it('has loading false by default', () => { + const { getByText } = render( + Not Loading + ); + + expect(getByText('Not Loading')).toBeTruthy(); + }); + + it('has fullWidth false by default', () => { + const { getByText } = render(Not Full Width); + expect(getByText('Not Full Width')).toBeTruthy(); + }); + }); +}); diff --git a/apps/mobile/src/__tests__/components/GlassCard.test.tsx b/apps/mobile/src/__tests__/components/GlassCard.test.tsx new file mode 100644 index 0000000..e9c8dce --- /dev/null +++ b/apps/mobile/src/__tests__/components/GlassCard.test.tsx @@ -0,0 +1,299 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { Text, Platform } from 'react-native'; +import { GlassCard } from '@/components/ui/GlassCard'; + +// Mock expo-blur +jest.mock('expo-blur', () => { + const React = require('react'); + const { View } = require('react-native'); + + return { + BlurView: ({ children, intensity, tint, style, ...props }: any) => + React.createElement( + View, + { ...props, style, testID: 'blur-view', 'data-intensity': intensity, 'data-tint': tint }, + children + ), + }; +}); + +// Store original Platform.OS +const originalPlatformOS = Platform.OS; + +describe('GlassCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset to iOS for consistent testing + Object.defineProperty(Platform, 'OS', { + get: () => 'ios', + }); + }); + + afterAll(() => { + Object.defineProperty(Platform, 'OS', { + get: () => originalPlatformOS, + }); + }); + + describe('rendering', () => { + it('renders children correctly', () => { + const { getByText } = render( + + Card Content + + ); + + expect(getByText('Card Content')).toBeTruthy(); + }); + + it('renders multiple children', () => { + const { getByText } = render( + + First Child + Second Child + + ); + + expect(getByText('First Child')).toBeTruthy(); + expect(getByText('Second Child')).toBeTruthy(); + }); + + it('renders nested components', () => { + const { getByText, getByTestId } = render( + + Nested Content + + ); + + expect(getByTestId('nested-text')).toBeTruthy(); + expect(getByText('Nested Content')).toBeTruthy(); + }); + }); + + describe('platform behavior', () => { + it('uses BlurView on iOS', () => { + Object.defineProperty(Platform, 'OS', { + get: () => 'ios', + }); + + const { getByTestId } = render( + + iOS Card + + ); + + expect(getByTestId('blur-view')).toBeTruthy(); + }); + + // Note: Testing Android platform requires component re-evaluation + // which is complex with module-level Platform checks + it('renders correctly', () => { + const { getByText } = render( + + Card Content + + ); + + expect(getByText('Card Content')).toBeTruthy(); + }); + }); + + describe('intensity prop', () => { + beforeEach(() => { + Object.defineProperty(Platform, 'OS', { + get: () => 'ios', + }); + }); + + it('applies light intensity', () => { + const { getByTestId } = render( + + Light Card + + ); + + const blurView = getByTestId('blur-view'); + expect(blurView).toBeTruthy(); + }); + + it('applies medium intensity by default', () => { + const { getByTestId } = render( + + Medium Card + + ); + + const blurView = getByTestId('blur-view'); + expect(blurView).toBeTruthy(); + }); + + it('applies heavy intensity', () => { + const { getByTestId } = render( + + Heavy Card + + ); + + const blurView = getByTestId('blur-view'); + expect(blurView).toBeTruthy(); + }); + }); + + describe('variant prop', () => { + it('renders default variant', () => { + const { getByText } = render( + + Default Card + + ); + + expect(getByText('Default Card')).toBeTruthy(); + }); + + it('renders elevated variant', () => { + const { getByText } = render( + + Elevated Card + + ); + + expect(getByText('Elevated Card')).toBeTruthy(); + }); + + it('renders subtle variant', () => { + const { getByText } = render( + + Subtle Card + + ); + + expect(getByText('Subtle Card')).toBeTruthy(); + }); + }); + + describe('noPadding prop', () => { + it('has padding by default', () => { + const { getByText } = render( + + Padded Card + + ); + + expect(getByText('Padded Card')).toBeTruthy(); + }); + + it('removes padding when noPadding is true', () => { + const { getByText } = render( + + No Padding Card + + ); + + expect(getByText('No Padding Card')).toBeTruthy(); + }); + }); + + describe('style prop', () => { + it('applies custom style', () => { + const customStyle = { marginTop: 20, backgroundColor: 'red' }; + const { getByText } = render( + + Styled Card + + ); + + expect(getByText('Styled Card')).toBeTruthy(); + }); + + it('merges custom style with default styles', () => { + const { getByText } = render( + + Merged Styles Card + + ); + + expect(getByText('Merged Styles Card')).toBeTruthy(); + }); + }); + + describe('default props', () => { + it('has default intensity of medium', () => { + const { getByText } = render( + + Default Intensity + + ); + + expect(getByText('Default Intensity')).toBeTruthy(); + }); + + it('has default variant of default', () => { + const { getByText } = render( + + Default Variant + + ); + + expect(getByText('Default Variant')).toBeTruthy(); + }); + + it('has noPadding false by default', () => { + const { getByText } = render( + + Has Padding + + ); + + expect(getByText('Has Padding')).toBeTruthy(); + }); + }); + + describe('gradient highlight', () => { + it('renders highlight gradient', () => { + Object.defineProperty(Platform, 'OS', { + get: () => 'ios', + }); + const { getByText } = render( + + Card with Highlight + + ); + + // LinearGradient is mocked to render as View, so we just verify the card renders + expect(getByText('Card with Highlight')).toBeTruthy(); + }); + }); + + describe('accessibility', () => { + it('renders children accessible to screen readers', () => { + const { getByText } = render( + + Content + + ); + + const text = getByText('Content'); + expect(text.props.accessibilityLabel).toBe('Accessible content'); + }); + }); + + describe('combination of props', () => { + it('handles all props together', () => { + const customStyle = { margin: 10 }; + + const { getByText } = render( + + Full Props Card + + ); + + expect(getByText('Full Props Card')).toBeTruthy(); + }); + }); +}); diff --git a/apps/mobile/src/__tests__/components/GlassInput.test.tsx b/apps/mobile/src/__tests__/components/GlassInput.test.tsx new file mode 100644 index 0000000..f951246 --- /dev/null +++ b/apps/mobile/src/__tests__/components/GlassInput.test.tsx @@ -0,0 +1,380 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { Text, Platform } from 'react-native'; +import { GlassInput } from '@/components/ui/GlassInput'; + +// Mock expo-blur +jest.mock('expo-blur', () => { + const React = require('react'); + const { View } = require('react-native'); + + return { + BlurView: ({ children, intensity, tint, style, ...props }: any) => + React.createElement( + View, + { ...props, style, testID: 'blur-view' }, + children + ), + }; +}); + +// Store original Platform.OS +const originalPlatformOS = Platform.OS; + +describe('GlassInput', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset to iOS for consistent testing + Object.defineProperty(Platform, 'OS', { + get: () => 'ios', + }); + }); + + afterAll(() => { + // Restore original + Object.defineProperty(Platform, 'OS', { + get: () => originalPlatformOS, + }); + }); + + describe('rendering', () => { + it('renders text input', () => { + const { getByPlaceholderText } = render( + + ); + + expect(getByPlaceholderText('Enter text')).toBeTruthy(); + }); + + it('renders with label', () => { + const { getByText, getByPlaceholderText } = render( + + ); + + expect(getByText('Username')).toBeTruthy(); + expect(getByPlaceholderText('Enter username')).toBeTruthy(); + }); + + it('renders without label', () => { + const { queryByText, getByPlaceholderText } = render( + + ); + + expect(getByPlaceholderText('No label input')).toBeTruthy(); + // No label text should be present + }); + + it('renders error message when provided', () => { + const { getByText } = render( + + ); + + expect(getByText('This field is required')).toBeTruthy(); + }); + + it('does not render error message when not provided', () => { + const { queryByText, getByPlaceholderText } = render( + + ); + + expect(getByPlaceholderText('No error input')).toBeTruthy(); + expect(queryByText(/error/i)).toBeNull(); + }); + + it('renders with icon', () => { + const TestIcon = () => Icon; + const { getByTestId } = render( + } /> + ); + + expect(getByTestId('input-icon')).toBeTruthy(); + }); + }); + + describe('platform behavior', () => { + it('uses BlurView on iOS', () => { + Object.defineProperty(Platform, 'OS', { + get: () => 'ios', + }); + + const { getByTestId } = render( + + ); + + expect(getByTestId('blur-view')).toBeTruthy(); + }); + + // Note: Testing Android platform requires component re-evaluation + // which is complex with module-level Platform checks + it('renders correctly', () => { + const { getByPlaceholderText } = render( + + ); + + expect(getByPlaceholderText('Input')).toBeTruthy(); + }); + }); + + describe('input interactions', () => { + it('handles text input changes', () => { + const onChangeText = jest.fn(); + const { getByPlaceholderText } = render( + + ); + + const input = getByPlaceholderText('Type here'); + fireEvent.changeText(input, 'Hello World'); + + expect(onChangeText).toHaveBeenCalledWith('Hello World'); + }); + + it('handles focus events', () => { + const onFocus = jest.fn(); + const { getByPlaceholderText } = render( + + ); + + const input = getByPlaceholderText('Focus me'); + fireEvent(input, 'focus', {}); + + expect(onFocus).toHaveBeenCalled(); + }); + + it('handles blur events', () => { + const onBlur = jest.fn(); + const { getByPlaceholderText } = render( + + ); + + const input = getByPlaceholderText('Blur me'); + fireEvent(input, 'blur', {}); + + expect(onBlur).toHaveBeenCalled(); + }); + + it('displays value prop', () => { + const { getByDisplayValue } = render( + + ); + + expect(getByDisplayValue('Initial value')).toBeTruthy(); + }); + + it('handles controlled input', () => { + const onChangeText = jest.fn(); + const { getByDisplayValue, rerender } = render( + + ); + + expect(getByDisplayValue('Initial')).toBeTruthy(); + + rerender( + + ); + + expect(getByDisplayValue('Updated')).toBeTruthy(); + }); + }); + + describe('focus state styling', () => { + it('updates styling on focus', () => { + const { getByPlaceholderText } = render( + + ); + + const input = getByPlaceholderText('Focus styling'); + + // Trigger focus + fireEvent(input, 'focus', {}); + + // The component should update without errors + expect(input).toBeTruthy(); + }); + + it('updates styling on blur', () => { + const { getByPlaceholderText } = render( + + ); + + const input = getByPlaceholderText('Blur styling'); + + // Trigger focus then blur + fireEvent(input, 'focus', {}); + fireEvent(input, 'blur', {}); + + expect(input).toBeTruthy(); + }); + }); + + describe('error state', () => { + it('displays error styling when error prop is provided', () => { + const { getByText, getByPlaceholderText } = render( + + ); + + expect(getByPlaceholderText('Error input')).toBeTruthy(); + expect(getByText('Invalid input')).toBeTruthy(); + }); + + it('applies error border color', () => { + const { getByPlaceholderText } = render( + + ); + + // The component renders with error styling + expect(getByPlaceholderText('Error border')).toBeTruthy(); + }); + }); + + describe('input props passthrough', () => { + it('passes secureTextEntry prop', () => { + const { getByPlaceholderText } = render( + + ); + + const input = getByPlaceholderText('Password'); + expect(input.props.secureTextEntry).toBe(true); + }); + + it('passes keyboardType prop', () => { + const { getByPlaceholderText } = render( + + ); + + const input = getByPlaceholderText('Email'); + expect(input.props.keyboardType).toBe('email-address'); + }); + + it('passes autoCapitalize prop', () => { + const { getByPlaceholderText } = render( + + ); + + const input = getByPlaceholderText('Name'); + expect(input.props.autoCapitalize).toBe('words'); + }); + + it('passes autoCorrect prop', () => { + const { getByPlaceholderText } = render( + + ); + + const input = getByPlaceholderText('Code'); + expect(input.props.autoCorrect).toBe(false); + }); + + it('passes maxLength prop', () => { + const { getByPlaceholderText } = render( + + ); + + const input = getByPlaceholderText('Short'); + expect(input.props.maxLength).toBe(10); + }); + + it('passes multiline prop', () => { + const { getByPlaceholderText } = render( + + ); + + const input = getByPlaceholderText('Message'); + expect(input.props.multiline).toBe(true); + }); + + it('passes editable prop', () => { + const { getByPlaceholderText } = render( + + ); + + const input = getByPlaceholderText('Read only'); + expect(input.props.editable).toBe(false); + }); + + it('passes returnKeyType prop', () => { + const { getByPlaceholderText } = render( + + ); + + const input = getByPlaceholderText('Search'); + expect(input.props.returnKeyType).toBe('search'); + }); + }); + + describe('containerStyle prop', () => { + it('applies custom container style', () => { + const customStyle = { marginTop: 20 }; + const { getByPlaceholderText } = render( + + ); + + expect(getByPlaceholderText('Styled container')).toBeTruthy(); + }); + }); + + describe('accessibility', () => { + it('supports accessibilityLabel', () => { + const { getByLabelText } = render( + + ); + + expect(getByLabelText('Username input field')).toBeTruthy(); + }); + + it('supports accessibilityHint', () => { + const { getByPlaceholderText } = render( + + ); + + const input = getByPlaceholderText('Hint input'); + expect(input.props.accessibilityHint).toBe('Enter your username'); + }); + }); + + describe('combination of props', () => { + it('handles all props together', () => { + const onChangeText = jest.fn(); + const onFocus = jest.fn(); + const TestIcon = () => Icon; + + const { getByText, getByPlaceholderText } = render( + } + value="Initial" + onChangeText={onChangeText} + onFocus={onFocus} + secureTextEntry + keyboardType="default" + containerStyle={{ margin: 10 }} + /> + ); + + expect(getByText('Full Props Input')).toBeTruthy(); + expect(getByPlaceholderText('Enter value')).toBeTruthy(); + expect(getByText('Validation error')).toBeTruthy(); + expect(getByText('Icon')).toBeTruthy(); + }); + }); +}); diff --git a/apps/mobile/src/__tests__/components/LoadingOverlay.test.tsx b/apps/mobile/src/__tests__/components/LoadingOverlay.test.tsx new file mode 100644 index 0000000..3ba5962 --- /dev/null +++ b/apps/mobile/src/__tests__/components/LoadingOverlay.test.tsx @@ -0,0 +1,255 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { LoadingOverlay } from '@/components/ui/LoadingOverlay'; + +describe('LoadingOverlay', () => { + describe('visibility', () => { + it('returns null when not visible', () => { + const { queryByText, toJSON } = render( + + ); + + expect(queryByText('Loading...')).toBeNull(); + expect(toJSON()).toBeNull(); + }); + + it('renders when visible', () => { + const { getByText } = render( + + ); + + expect(getByText('Loading...')).toBeTruthy(); + }); + }); + + describe('spinner variant (default)', () => { + it('renders spinner by default', () => { + const { UNSAFE_getByType } = render( + + ); + + const ActivityIndicator = require('react-native').ActivityIndicator; + expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy(); + }); + + it('renders status text', () => { + const { getByText } = render( + + ); + + expect(getByText('Please wait...')).toBeTruthy(); + }); + + it('renders detail text', () => { + const { getByText } = render( + + ); + + expect(getByText('This may take a moment')).toBeTruthy(); + }); + + it('renders both status and detail text', () => { + const { getByText } = render( + + ); + + expect(getByText('Uploading...')).toBeTruthy(); + expect(getByText('50% complete')).toBeTruthy(); + }); + }); + + describe('shimmer variant', () => { + it('renders shimmer variant', () => { + const { UNSAFE_getByType, getByText } = render( + + ); + + const ActivityIndicator = require('react-native').ActivityIndicator; + expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy(); + expect(getByText('Loading...')).toBeTruthy(); + }); + + it('renders shimmer with detail text', () => { + const { getByText } = render( + + ); + + expect(getByText('Syncing')).toBeTruthy(); + expect(getByText('Almost done')).toBeTruthy(); + }); + }); + + describe('progress variant', () => { + it('renders progress variant', () => { + const { getByText } = render( + + ); + + expect(getByText('Uploading...')).toBeTruthy(); + }); + + it('renders progress with detail text', () => { + const { getByText } = render( + + ); + + expect(getByText('Processing')).toBeTruthy(); + expect(getByText('3 of 4 complete')).toBeTruthy(); + }); + + it('clamps progress to 0-100', () => { + // Progress value should be clamped internally + const { toJSON } = render( + + ); + + // Should render without error + expect(toJSON()).toBeTruthy(); + }); + + it('handles negative progress', () => { + const { toJSON } = render( + + ); + + // Should render without error + expect(toJSON()).toBeTruthy(); + }); + }); + + describe('fullScreen mode', () => { + it('renders in fullScreen mode', () => { + const { getByText } = render( + + ); + + expect(getByText('Full screen loading')).toBeTruthy(); + }); + + it('renders fullScreen with different variants', () => { + const { rerender, getByText } = render( + + ); + expect(getByText('Spinner')).toBeTruthy(); + + rerender( + + ); + expect(getByText('Progress')).toBeTruthy(); + + rerender( + + ); + expect(getByText('Shimmer')).toBeTruthy(); + }); + }); + + describe('custom styling', () => { + it('applies custom style', () => { + const customStyle = { marginTop: 20 }; + const { getByText } = render( + + ); + + expect(getByText('Styled')).toBeTruthy(); + }); + + it('applies custom text style', () => { + const customTextStyle = { fontSize: 20 }; + const { getByText } = render( + + ); + + expect(getByText('Custom text')).toBeTruthy(); + }); + + it('uses custom spinner color', () => { + const { UNSAFE_getByType } = render( + + ); + + const ActivityIndicator = require('react-native').ActivityIndicator; + const indicator = UNSAFE_getByType(ActivityIndicator); + expect(indicator.props.color).toBe('#FF0000'); + }); + }); + + describe('default props', () => { + it('has spinner variant by default', () => { + const { UNSAFE_getByType } = render( + + ); + + const ActivityIndicator = require('react-native').ActivityIndicator; + expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy(); + }); + + it('has fullScreen false by default', () => { + const { toJSON } = render( + + ); + + // Should render in non-fullscreen mode + expect(toJSON()).toBeTruthy(); + }); + + it('has progress 0 by default', () => { + const { toJSON } = render( + + ); + + // Should render without error + expect(toJSON()).toBeTruthy(); + }); + }); +}); diff --git a/apps/mobile/src/__tests__/components/ShimmerEffect.test.tsx b/apps/mobile/src/__tests__/components/ShimmerEffect.test.tsx new file mode 100644 index 0000000..f9e27e5 --- /dev/null +++ b/apps/mobile/src/__tests__/components/ShimmerEffect.test.tsx @@ -0,0 +1,186 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { Text, View } from 'react-native'; +import { ShimmerEffect } from '@/components/ui/ShimmerEffect'; + +describe('ShimmerEffect', () => { + describe('rendering', () => { + it('renders children correctly', () => { + const { getByText } = render( + + Child Content + + ); + + expect(getByText('Child Content')).toBeTruthy(); + }); + + it('renders multiple children', () => { + const { getByText } = render( + + First Child + Second Child + + ); + + expect(getByText('First Child')).toBeTruthy(); + expect(getByText('Second Child')).toBeTruthy(); + }); + + it('renders nested components', () => { + const { getByText, getByTestId } = render( + + + Nested Content + + + ); + + expect(getByTestId('nested-view')).toBeTruthy(); + expect(getByText('Nested Content')).toBeTruthy(); + }); + }); + + describe('enabled prop', () => { + it('shows glow effect when enabled (default)', () => { + const { toJSON } = render( + + Content + + ); + + // Should render with glow elements + expect(toJSON()).toBeTruthy(); + }); + + it('hides glow effect when disabled', () => { + const { getByText, toJSON } = render( + + Content + + ); + + expect(getByText('Content')).toBeTruthy(); + // The glow container should not be rendered when disabled + expect(toJSON()).toBeTruthy(); + }); + + it('toggles enabled state', () => { + const { rerender, getByText } = render( + + Content + + ); + expect(getByText('Content')).toBeTruthy(); + + rerender( + + Content + + ); + expect(getByText('Content')).toBeTruthy(); + }); + }); + + describe('custom styling', () => { + it('applies custom style', () => { + const customStyle = { margin: 10, padding: 20 }; + const { getByText } = render( + + Styled Content + + ); + + expect(getByText('Styled Content')).toBeTruthy(); + }); + + it('uses custom shimmer color', () => { + const { getByText } = render( + + Red Shimmer + + ); + + expect(getByText('Red Shimmer')).toBeTruthy(); + }); + }); + + describe('duration prop', () => { + it('accepts custom duration', () => { + const { getByText } = render( + + Fast Shimmer + + ); + + expect(getByText('Fast Shimmer')).toBeTruthy(); + }); + + it('uses default duration of 4000ms', () => { + const { getByText } = render( + + Default Duration + + ); + + expect(getByText('Default Duration')).toBeTruthy(); + }); + }); + + describe('default props', () => { + it('has enabled true by default', () => { + const { toJSON, getByText } = render( + + Content + + ); + + expect(getByText('Content')).toBeTruthy(); + expect(toJSON()).toBeTruthy(); + }); + + it('uses default shimmer color', () => { + const { getByText } = render( + + Default Color + + ); + + expect(getByText('Default Color')).toBeTruthy(); + }); + }); + + describe('composition', () => { + it('works with complex children', () => { + const { getByText, getByTestId } = render( + + + Header + + Body + + Footer + + + ); + + expect(getByTestId('outer')).toBeTruthy(); + expect(getByTestId('inner')).toBeTruthy(); + expect(getByText('Header')).toBeTruthy(); + expect(getByText('Body')).toBeTruthy(); + expect(getByText('Footer')).toBeTruthy(); + }); + + it('can be nested', () => { + const { getByText } = render( + + + Nested Shimmer + + + ); + + expect(getByText('Nested Shimmer')).toBeTruthy(); + }); + }); +}); diff --git a/apps/mobile/src/__tests__/hooks/useFeatureInterest.test.ts b/apps/mobile/src/__tests__/hooks/useFeatureInterest.test.ts new file mode 100644 index 0000000..83783f9 --- /dev/null +++ b/apps/mobile/src/__tests__/hooks/useFeatureInterest.test.ts @@ -0,0 +1,197 @@ +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { useFeatureInterest } from '@/hooks/useFeatureInterest'; +import { useAuthStore } from '@/store/authStore'; +import { supabase } from '@/lib/supabase'; + +function createThenableQuery(result: any) { + const query: any = { + select: jest.fn(() => query), + eq: jest.fn(() => query), + insert: jest.fn(() => query), + delete: jest.fn(() => query), + maybeSingle: jest.fn(() => query), + then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject), + }; + return query; +} + +describe('useFeatureInterest', () => { + beforeEach(() => { + jest.clearAllMocks(); + useAuthStore.setState({ user: { id: 'user1' } } as any); + }); + + it('fetches initial interest status as true when interest exists', async () => { + const query = createThenableQuery({ data: { id: 'interest1' } }); + (supabase.from as jest.Mock).mockReturnValue(query); + + const { result } = renderHook(() => useFeatureInterest('dares')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isInterested).toBe(true); + expect(result.current.isAuthenticated).toBe(true); + expect(query.eq).toHaveBeenCalledWith('user_id', 'user1'); + expect(query.eq).toHaveBeenCalledWith('feature_name', 'dares'); + }); + + it('fetches initial interest status as false when no interest exists', async () => { + const query = createThenableQuery({ data: null }); + (supabase.from as jest.Mock).mockReturnValue(query); + + const { result } = renderHook(() => useFeatureInterest('dares')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isInterested).toBe(false); + }); + + it('returns isAuthenticated false when user is null', async () => { + useAuthStore.setState({ user: null } as any); + + const { result } = renderHook(() => useFeatureInterest('dares')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.isInterested).toBe(false); + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it('adds interest when toggling from not interested', async () => { + // Initial query returns no interest + const initialQuery = createThenableQuery({ data: null }); + const insertQuery = createThenableQuery({ error: null }); + + (supabase.from as jest.Mock) + .mockReturnValueOnce(initialQuery) // Initial fetch + .mockReturnValueOnce(insertQuery); // Insert on toggle + + const { result } = renderHook(() => useFeatureInterest('dares')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isInterested).toBe(false); + + await act(async () => { + await result.current.toggleInterest(); + }); + + expect(result.current.isInterested).toBe(true); + expect(insertQuery.insert).toHaveBeenCalledWith({ + user_id: 'user1', + feature_name: 'dares', + }); + }); + + it('removes interest when toggling from interested', async () => { + // Initial query returns existing interest + const initialQuery = createThenableQuery({ data: { id: 'interest1' } }); + const deleteQuery = createThenableQuery({ error: null }); + + (supabase.from as jest.Mock) + .mockReturnValueOnce(initialQuery) // Initial fetch + .mockReturnValueOnce(deleteQuery); // Delete on toggle + + const { result } = renderHook(() => useFeatureInterest('dares')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isInterested).toBe(true); + + await act(async () => { + await result.current.toggleInterest(); + }); + + expect(result.current.isInterested).toBe(false); + expect(deleteQuery.delete).toHaveBeenCalled(); + expect(deleteQuery.eq).toHaveBeenCalledWith('user_id', 'user1'); + expect(deleteQuery.eq).toHaveBeenCalledWith('feature_name', 'dares'); + }); + + it('reverts optimistic update on error', async () => { + const initialQuery = createThenableQuery({ data: null }); + const insertQuery = createThenableQuery({ error: new Error('Insert failed') }); + + (supabase.from as jest.Mock) + .mockReturnValueOnce(initialQuery) + .mockReturnValueOnce(insertQuery); + + const { result } = renderHook(() => useFeatureInterest('dares')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isInterested).toBe(false); + + await act(async () => { + await result.current.toggleInterest(); + }); + + // Should revert to original state after error + expect(result.current.isInterested).toBe(false); + }); + + it('does not toggle when user is not authenticated', async () => { + useAuthStore.setState({ user: null } as any); + + const { result } = renderHook(() => useFeatureInterest('dares')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.toggleInterest(); + }); + + // Should not make any API calls + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it('isToggling is false when not toggling', async () => { + const initialQuery = createThenableQuery({ data: null }); + + (supabase.from as jest.Mock).mockReturnValueOnce(initialQuery); + + const { result } = renderHook(() => useFeatureInterest('dares')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isToggling).toBe(false); + }); + + it('isToggling is false after toggle completes', async () => { + const initialQuery = createThenableQuery({ data: null }); + const insertQuery = createThenableQuery({ error: null }); + + (supabase.from as jest.Mock) + .mockReturnValueOnce(initialQuery) + .mockReturnValueOnce(insertQuery); + + const { result } = renderHook(() => useFeatureInterest('dares')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.toggleInterest(); + }); + + expect(result.current.isToggling).toBe(false); + }); +}); diff --git a/apps/mobile/src/__tests__/hooks/useMediaPicker.test.ts b/apps/mobile/src/__tests__/hooks/useMediaPicker.test.ts index 494f017..7618f5b 100644 --- a/apps/mobile/src/__tests__/hooks/useMediaPicker.test.ts +++ b/apps/mobile/src/__tests__/hooks/useMediaPicker.test.ts @@ -6,91 +6,355 @@ import { useMediaPicker } from '@/hooks/useMediaPicker'; describe('useMediaPicker', () => { beforeEach(() => { jest.clearAllMocks(); + jest.useFakeTimers(); }); - it('returns null when user cancels library picker', async () => { - (ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({ - canceled: true, - assets: [], - }); - - const { result } = renderHook(() => useMediaPicker()); - - let picked: any; - await act(async () => { - picked = await result.current.pickMedia(); - }); - - expect(picked).toBeNull(); + afterEach(() => { + jest.useRealTimers(); }); - it('maps selected image to MediaResult', async () => { - (ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({ - canceled: false, - assets: [{ + describe('pickMedia', () => { + it('returns null when user cancels library picker', async () => { + (ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({ + canceled: true, + assets: [], + }); + + const { result } = renderHook(() => useMediaPicker()); + + let picked: any; + await act(async () => { + picked = await result.current.pickMedia(); + }); + + expect(picked).toBeNull(); + }); + + it('maps selected image to MediaResult', async () => { + (ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({ + canceled: false, + assets: [{ + uri: 'file://image.jpg', + type: 'image', + width: 100, + height: 200, + }], + }); + + const { result } = renderHook(() => useMediaPicker()); + + let picked: any; + await act(async () => { + picked = await result.current.pickMedia(); + }); + + expect(picked).toEqual({ uri: 'file://image.jpg', - type: 'image', + mediaType: 'image', width: 100, height: 200, - }], + duration: undefined, + }); }); - const { result } = renderHook(() => useMediaPicker()); + it('maps selected video to MediaResult', async () => { + (ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({ + canceled: false, + assets: [{ + uri: 'file://video.mp4', + type: 'video', + width: 640, + height: 480, + duration: 1234, + }], + }); - let picked: any; - await act(async () => { - picked = await result.current.pickMedia(); - }); + const { result } = renderHook(() => useMediaPicker()); - expect(picked).toEqual({ - uri: 'file://image.jpg', - mediaType: 'image', - width: 100, - height: 200, - duration: undefined, - }); - }); + let picked: any; + await act(async () => { + picked = await result.current.pickMedia(); + }); - it('maps selected video to MediaResult', async () => { - (ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({ - canceled: false, - assets: [{ + expect(picked).toEqual({ uri: 'file://video.mp4', - type: 'video', + mediaType: 'video', width: 640, height: 480, duration: 1234, - }], + }); }); - const { result } = renderHook(() => useMediaPicker()); + it('uses custom config options', async () => { + (ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({ + canceled: true, + }); - let picked: any; - await act(async () => { - picked = await result.current.pickMedia(); - }); + const { result } = renderHook(() => useMediaPicker({ + imageQuality: 0.5, + libraryVideoMaxDuration: 120, + })); - expect(picked).toEqual({ - uri: 'file://video.mp4', - mediaType: 'video', - width: 640, - height: 480, - duration: 1234, + await act(async () => { + await result.current.pickMedia(); + }); + + expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({ + mediaTypes: ImagePicker.MediaTypeOptions.All, + quality: 0.5, + videoMaxDuration: 120, + }); }); }); - it('alerts and returns false when camera permission denied', async () => { - const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); - (ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' }); + describe('requestCameraPermission', () => { + it('returns true when permission granted', async () => { + (ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' }); - const { result } = renderHook(() => useMediaPicker()); + const { result } = renderHook(() => useMediaPicker()); - let ok: any; - await act(async () => { - ok = await result.current.requestCameraPermission(); + let ok: any; + await act(async () => { + ok = await result.current.requestCameraPermission(); + }); + + expect(ok).toBe(true); }); - expect(ok).toBe(false); - expect(alertSpy).toHaveBeenCalled(); + it('alerts and returns false when camera permission denied', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + (ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' }); + + const { result } = renderHook(() => useMediaPicker()); + + let ok: any; + await act(async () => { + ok = await result.current.requestCameraPermission(); + }); + + expect(ok).toBe(false); + expect(alertSpy).toHaveBeenCalledWith( + 'Camera Permission', + 'Please allow camera access to use this feature.' + ); + }); + }); + + describe('takePhoto', () => { + it('takes photo successfully', async () => { + (ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' }); + (ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ + canceled: false, + assets: [{ + uri: 'file://photo.jpg', + type: 'image', + width: 3024, + height: 4032, + }], + }); + + const { result } = renderHook(() => useMediaPicker()); + + let photo: any; + await act(async () => { + photo = await result.current.takePhoto(); + }); + + expect(photo).toEqual({ + uri: 'file://photo.jpg', + mediaType: 'image', + width: 3024, + height: 4032, + }); + }); + + it('returns null when permission denied', async () => { + jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + (ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' }); + + const { result } = renderHook(() => useMediaPicker()); + + let photo: any; + await act(async () => { + photo = await result.current.takePhoto(); + }); + + expect(photo).toBeNull(); + expect(ImagePicker.launchCameraAsync).not.toHaveBeenCalled(); + }); + + it('returns null when cancelled', async () => { + (ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' }); + (ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ + canceled: true, + }); + + const { result } = renderHook(() => useMediaPicker()); + + let photo: any; + await act(async () => { + photo = await result.current.takePhoto(); + }); + + expect(photo).toBeNull(); + }); + + it('uses custom image quality', async () => { + (ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' }); + (ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ canceled: true }); + + const { result } = renderHook(() => useMediaPicker({ imageQuality: 0.9 })); + + await act(async () => { + await result.current.takePhoto(); + }); + + expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith({ quality: 0.9 }); + }); + }); + + describe('recordVideo', () => { + it('records video successfully', async () => { + (ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' }); + (ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ + canceled: false, + assets: [{ + uri: 'file://video.mp4', + type: 'video', + width: 1920, + height: 1080, + duration: 15000, + }], + }); + + const { result } = renderHook(() => useMediaPicker()); + + let video: any; + await act(async () => { + video = await result.current.recordVideo(); + }); + + expect(video).toEqual({ + uri: 'file://video.mp4', + mediaType: 'video', + width: 1920, + height: 1080, + duration: 15000, + }); + }); + + it('returns null when permission denied', async () => { + jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + (ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' }); + + const { result } = renderHook(() => useMediaPicker()); + + let video: any; + await act(async () => { + video = await result.current.recordVideo(); + }); + + expect(video).toBeNull(); + }); + + it('returns null when cancelled', async () => { + (ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' }); + (ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ canceled: true }); + + const { result } = renderHook(() => useMediaPicker()); + + let video: any; + await act(async () => { + video = await result.current.recordVideo(); + }); + + expect(video).toBeNull(); + }); + + it('uses custom video max duration', async () => { + (ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' }); + (ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ canceled: true }); + + const { result } = renderHook(() => useMediaPicker({ cameraVideoMaxDuration: 30 })); + + await act(async () => { + await result.current.recordVideo(); + }); + + expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith( + expect.objectContaining({ + videoMaxDuration: 30, + mediaTypes: ImagePicker.MediaTypeOptions.Videos, + }) + ); + }); + + it('handles null duration in video result', async () => { + (ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' }); + (ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ + canceled: false, + assets: [{ + uri: 'file://video.mp4', + type: 'video', + width: 1920, + height: 1080, + duration: null, + }], + }); + + const { result } = renderHook(() => useMediaPicker()); + + let video: any; + await act(async () => { + video = await result.current.recordVideo(); + }); + + expect(video.duration).toBeUndefined(); + }); + }); + + describe('default config', () => { + it('uses default image quality of 0.7', async () => { + (ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({ canceled: true }); + + const { result } = renderHook(() => useMediaPicker()); + + await act(async () => { + await result.current.pickMedia(); + }); + + expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith( + expect.objectContaining({ quality: 0.7 }) + ); + }); + + it('uses default library video max duration of 300', async () => { + (ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({ canceled: true }); + + const { result } = renderHook(() => useMediaPicker()); + + await act(async () => { + await result.current.pickMedia(); + }); + + expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith( + expect.objectContaining({ videoMaxDuration: 300 }) + ); + }); + + it('uses default camera video max duration of 60', async () => { + (ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' }); + (ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ canceled: true }); + + const { result } = renderHook(() => useMediaPicker()); + + await act(async () => { + await result.current.recordVideo(); + }); + + expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith( + expect.objectContaining({ videoMaxDuration: 60 }) + ); + }); }); }); diff --git a/apps/mobile/src/__tests__/hooks/useMediaSaver.test.ts b/apps/mobile/src/__tests__/hooks/useMediaSaver.test.ts new file mode 100644 index 0000000..fc92240 --- /dev/null +++ b/apps/mobile/src/__tests__/hooks/useMediaSaver.test.ts @@ -0,0 +1,231 @@ +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { Alert } from 'react-native'; +import * as MediaLibrary from 'expo-media-library'; +import * as FileSystem from 'expo-file-system'; +import { useMediaSaver } from '@/hooks/useMediaSaver'; + +// Mock expo-media-library +jest.mock('expo-media-library', () => ({ + usePermissions: jest.fn(), + createAssetAsync: jest.fn(), +})); + +// Mock expo-file-system with all required methods +jest.mock('expo-file-system', () => ({ + readAsStringAsync: jest.fn(async () => ''), + downloadAsync: jest.fn(), + deleteAsync: jest.fn(), + cacheDirectory: 'file:///cache/', + EncodingType: { + Base64: 'base64', + }, +})); + +// Mock Alert +jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + +describe('useMediaSaver', () => { + const mockRequestPermission = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + // Default: permission granted + (MediaLibrary.usePermissions as jest.Mock).mockReturnValue([ + { granted: true }, + mockRequestPermission, + ]); + (MediaLibrary.createAssetAsync as jest.Mock).mockResolvedValue({ id: 'asset1' }); + (FileSystem.downloadAsync as jest.Mock).mockResolvedValue({ + uri: 'file:///cache/download_123.jpg', + }); + (FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined); + }); + + it('initializes with saving as false', () => { + const { result } = renderHook(() => useMediaSaver()); + + expect(result.current.saving).toBe(false); + expect(typeof result.current.saveMedia).toBe('function'); + }); + + it('saves local image successfully', async () => { + const { result } = renderHook(() => useMediaSaver()); + + await act(async () => { + await result.current.saveMedia('file:///local/image.jpg', 'image'); + }); + + expect(MediaLibrary.createAssetAsync).toHaveBeenCalledWith('file:///local/image.jpg'); + expect(Alert.alert).toHaveBeenCalledWith('Saved', 'Image saved to your gallery.'); + expect(result.current.saving).toBe(false); + }); + + it('saves local video successfully', async () => { + const { result } = renderHook(() => useMediaSaver()); + + await act(async () => { + await result.current.saveMedia('file:///local/video.mp4', 'video'); + }); + + expect(MediaLibrary.createAssetAsync).toHaveBeenCalledWith('file:///local/video.mp4'); + expect(Alert.alert).toHaveBeenCalledWith('Saved', 'Video saved to your gallery.'); + }); + + it('downloads remote image before saving', async () => { + const { result } = renderHook(() => useMediaSaver()); + + await act(async () => { + await result.current.saveMedia('https://example.com/image.jpg', 'image'); + }); + + expect(FileSystem.downloadAsync).toHaveBeenCalledWith( + 'https://example.com/image.jpg', + expect.stringMatching(/download_\d+\.jpg$/) + ); + expect(MediaLibrary.createAssetAsync).toHaveBeenCalledWith('file:///cache/download_123.jpg'); + expect(FileSystem.deleteAsync).toHaveBeenCalledWith('file:///cache/download_123.jpg', { idempotent: true }); + }); + + it('downloads remote video before saving', async () => { + (FileSystem.downloadAsync as jest.Mock).mockResolvedValue({ + uri: 'file:///cache/download_123.mp4', + }); + + const { result } = renderHook(() => useMediaSaver()); + + await act(async () => { + await result.current.saveMedia('https://example.com/video.mp4', 'video'); + }); + + expect(FileSystem.downloadAsync).toHaveBeenCalledWith( + 'https://example.com/video.mp4', + expect.stringMatching(/download_\d+\.mp4$/) + ); + expect(MediaLibrary.createAssetAsync).toHaveBeenCalledWith('file:///cache/download_123.mp4'); + }); + + it('handles http URLs (upgrades to https internally)', async () => { + const { result } = renderHook(() => useMediaSaver()); + + await act(async () => { + await result.current.saveMedia('http://example.com/image.jpg', 'image'); + }); + + // http URLs are also considered remote + expect(FileSystem.downloadAsync).toHaveBeenCalled(); + }); + + it('requests permission when not granted', async () => { + (MediaLibrary.usePermissions as jest.Mock).mockReturnValue([ + { granted: false }, + mockRequestPermission, + ]); + mockRequestPermission.mockResolvedValue({ granted: true }); + + const { result } = renderHook(() => useMediaSaver()); + + await act(async () => { + await result.current.saveMedia('file:///local/image.jpg', 'image'); + }); + + expect(mockRequestPermission).toHaveBeenCalled(); + expect(MediaLibrary.createAssetAsync).toHaveBeenCalled(); + }); + + it('shows alert and returns early when permission denied', async () => { + (MediaLibrary.usePermissions as jest.Mock).mockReturnValue([ + { granted: false }, + mockRequestPermission, + ]); + mockRequestPermission.mockResolvedValue({ granted: false }); + + const { result } = renderHook(() => useMediaSaver()); + + await act(async () => { + await result.current.saveMedia('file:///local/image.jpg', 'image'); + }); + + expect(Alert.alert).toHaveBeenCalledWith( + 'Permission required', + 'Please allow access to your photo library to save media.' + ); + expect(MediaLibrary.createAssetAsync).not.toHaveBeenCalled(); + expect(result.current.saving).toBe(false); + }); + + it('handles save errors gracefully', async () => { + (MediaLibrary.createAssetAsync as jest.Mock).mockRejectedValue(new Error('Save failed')); + + const { result } = renderHook(() => useMediaSaver()); + + await act(async () => { + await result.current.saveMedia('file:///local/image.jpg', 'image'); + }); + + expect(Alert.alert).toHaveBeenCalledWith('Error', 'Failed to save media.'); + expect(result.current.saving).toBe(false); + }); + + it('handles download errors gracefully', async () => { + (FileSystem.downloadAsync as jest.Mock).mockRejectedValue(new Error('Download failed')); + + const { result } = renderHook(() => useMediaSaver()); + + await act(async () => { + await result.current.saveMedia('https://example.com/image.jpg', 'image'); + }); + + expect(Alert.alert).toHaveBeenCalledWith('Error', 'Failed to save media.'); + expect(result.current.saving).toBe(false); + }); + + it('sets saving to true during operation', async () => { + let savingDuringOperation = false; + + (MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => { + // This would capture the saving state during the async operation + // but since we can't access result.current here synchronously, + // we'll test this differently + return { id: 'asset1' }; + }); + + const { result } = renderHook(() => useMediaSaver()); + + const savePromise = act(async () => { + // Check saving is true after calling but before completion + const promise = result.current.saveMedia('file:///local/image.jpg', 'image'); + // Can't reliably check intermediate state, so we just verify final state + await promise; + }); + + await savePromise; + expect(result.current.saving).toBe(false); + }); + + it('does not delete local files after saving', async () => { + const { result } = renderHook(() => useMediaSaver()); + + await act(async () => { + await result.current.saveMedia('file:///local/image.jpg', 'image'); + }); + + expect(FileSystem.deleteAsync).not.toHaveBeenCalled(); + }); + + it('cleans up downloaded file after saving remote URL', async () => { + (FileSystem.downloadAsync as jest.Mock).mockResolvedValue({ + uri: 'file:///cache/download_123.jpg', + }); + + const { result } = renderHook(() => useMediaSaver()); + + await act(async () => { + await result.current.saveMedia('https://example.com/image.jpg', 'image'); + }); + + expect(FileSystem.deleteAsync).toHaveBeenCalledWith( + 'file:///cache/download_123.jpg', + { idempotent: true } + ); + }); +}); diff --git a/apps/mobile/src/__tests__/stores/matchStore.test.ts b/apps/mobile/src/__tests__/stores/matchStore.test.ts index 95678a7..8946088 100644 --- a/apps/mobile/src/__tests__/stores/matchStore.test.ts +++ b/apps/mobile/src/__tests__/stores/matchStore.test.ts @@ -11,6 +11,9 @@ function createThenableQuery(result: any) { in: jest.fn(() => query), order: jest.fn(() => query), range: jest.fn(() => query), + update: jest.fn(() => query), + insert: jest.fn(() => query), + delete: jest.fn(() => query), then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject), }; return query; @@ -19,31 +22,464 @@ function createThenableQuery(result: any) { describe('matchStore', () => { beforeEach(() => { jest.clearAllMocks(); - useMatchStore.setState({ matches: [], newMatchesCount: 0, isLoading: false, error: null } as any); + useMatchStore.setState({ + matches: [], + newMatchesCount: 0, + totalCount: null, + isLoading: false, + isLoadingMore: false, + page: 0, + hasMore: true, + error: null, + archivedMatches: [], + archivedMatchIds: new Set(), + showArchived: false, + isLoadingArchived: false, + }); useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any); }); - it('sorts matches with unread messages first', async () => { - const matches = [ - { id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: true }, - { id: 'b', question_id: 'q2', created_at: '2024-01-03T00:00:00.000Z', is_new: false }, - ]; + describe('fetchMatches', () => { + it('sorts matches with unread messages first', async () => { + const matches = [ + { id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: true }, + { id: 'b', question_id: 'q2', created_at: '2024-01-03T00:00:00.000Z', is_new: false }, + ]; - const unreadMessages = [{ match_id: 'a' }, { match_id: 'a' }]; + const unreadMessages = [{ match_id: 'a' }, { match_id: 'a' }]; - const matchesQuery = createThenableQuery({ data: matches }); - const responsesQuery = createThenableQuery({ data: [] }); - const unreadQuery = createThenableQuery({ data: unreadMessages, error: null }); + const archivesQuery = createThenableQuery({ data: [] }); + const countQuery = createThenableQuery({ count: 2 }); + const matchesQuery = createThenableQuery({ data: matches }); + const responsesQuery = createThenableQuery({ data: [] }); + const unreadQuery = createThenableQuery({ data: unreadMessages, error: null }); - (supabase.from as jest.Mock) - .mockReturnValueOnce(matchesQuery) // matches - .mockReturnValueOnce(responsesQuery) // responses - .mockReturnValueOnce(unreadQuery); // messages + (supabase.from as jest.Mock) + .mockReturnValueOnce(archivesQuery) // archived matches + .mockReturnValueOnce(countQuery) // count + .mockReturnValueOnce(matchesQuery) // matches + .mockReturnValueOnce(responsesQuery) // responses + .mockReturnValueOnce(unreadQuery); // messages - await useMatchStore.getState().fetchMatches(); + await useMatchStore.getState().fetchMatches(true); - const state = useMatchStore.getState(); - expect(state.matches[0].id).toBe('a'); - expect(state.newMatchesCount).toBe(1); + const state = useMatchStore.getState(); + expect(state.matches[0].id).toBe('a'); + expect(state.newMatchesCount).toBe(1); + }); + + it('sets empty state when user has no couple', async () => { + useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any); + + await useMatchStore.getState().fetchMatches(true); + + const state = useMatchStore.getState(); + expect(state.matches).toEqual([]); + expect(state.newMatchesCount).toBe(0); + expect(state.totalCount).toBe(0); + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it('prevents concurrent loading', async () => { + useMatchStore.setState({ isLoading: true }); + + await useMatchStore.getState().fetchMatches(true); + + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it('filters out archived matches', async () => { + const matches = [ + { id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: false }, + { id: 'b', question_id: 'q2', created_at: '2024-01-03T00:00:00.000Z', is_new: false }, + ]; + + const archivesQuery = createThenableQuery({ data: [{ match_id: 'a' }] }); + const countQuery = createThenableQuery({ count: 2 }); + const matchesQuery = createThenableQuery({ data: matches }); + const responsesQuery = createThenableQuery({ data: [] }); + const unreadQuery = createThenableQuery({ data: [], error: null }); + + (supabase.from as jest.Mock) + .mockReturnValueOnce(archivesQuery) + .mockReturnValueOnce(countQuery) + .mockReturnValueOnce(matchesQuery) + .mockReturnValueOnce(responsesQuery) + .mockReturnValueOnce(unreadQuery); + + await useMatchStore.getState().fetchMatches(true); + + const state = useMatchStore.getState(); + expect(state.matches).toHaveLength(1); + expect(state.matches[0].id).toBe('b'); + // Total count should subtract archived + expect(state.totalCount).toBe(1); + }); + + it('handles empty matches correctly', async () => { + const archivesQuery = createThenableQuery({ data: [] }); + const countQuery = createThenableQuery({ count: 0 }); + const matchesQuery = createThenableQuery({ data: [] }); + + (supabase.from as jest.Mock) + .mockReturnValueOnce(archivesQuery) + .mockReturnValueOnce(countQuery) + .mockReturnValueOnce(matchesQuery); + + await useMatchStore.getState().fetchMatches(true); + + const state = useMatchStore.getState(); + expect(state.matches).toEqual([]); + expect(state.hasMore).toBe(false); + expect(state.isLoading).toBe(false); + }); + + it('handles fetch error gracefully', async () => { + const archivesQuery = createThenableQuery({ data: [] }); + const countQuery = createThenableQuery({ count: 0 }); + // Use createThenableQuery with error data to simulate error response + const matchesQuery = createThenableQuery({ data: null, error: { message: 'Network error' } }); + + (supabase.from as jest.Mock) + .mockReturnValueOnce(archivesQuery) + .mockReturnValueOnce(countQuery) + .mockReturnValueOnce(matchesQuery); + + await useMatchStore.getState().fetchMatches(true); + + const state = useMatchStore.getState(); + expect(state.error).toBe('Failed to load matches'); + expect(state.isLoading).toBe(false); + }); + + it('paginates matches correctly', async () => { + useMatchStore.setState({ + matches: [{ id: 'existing' }] as any, + page: 1, + hasMore: true, + }); + + const newMatches = [ + { id: 'c', question_id: 'q3', created_at: '2024-01-01T00:00:00.000Z', is_new: false }, + ]; + + const matchesQuery = createThenableQuery({ data: newMatches }); + const responsesQuery = createThenableQuery({ data: [] }); + const unreadQuery = createThenableQuery({ data: [] }); + + (supabase.from as jest.Mock) + .mockReturnValueOnce(matchesQuery) + .mockReturnValueOnce(responsesQuery) + .mockReturnValueOnce(unreadQuery); + + await useMatchStore.getState().fetchMatches(false); + + const state = useMatchStore.getState(); + expect(state.matches).toHaveLength(2); + expect(state.page).toBe(2); + }); + }); + + describe('markAsSeen', () => { + it('marks a match as seen and updates count', async () => { + useMatchStore.setState({ + matches: [ + { id: 'm1', is_new: true } as any, + { id: 'm2', is_new: true } as any, + ], + newMatchesCount: 2, + }); + + const updateQuery = createThenableQuery({}); + (supabase.from as jest.Mock).mockReturnValue(updateQuery); + + await useMatchStore.getState().markAsSeen('m1'); + + const state = useMatchStore.getState(); + expect(state.matches[0].is_new).toBe(false); + expect(state.matches[1].is_new).toBe(true); + expect(state.newMatchesCount).toBe(1); + expect(updateQuery.update).toHaveBeenCalledWith({ is_new: false }); + }); + }); + + describe('markAllAsSeen', () => { + it('marks all matches as seen', async () => { + useMatchStore.setState({ + matches: [ + { id: 'm1', is_new: true } as any, + { id: 'm2', is_new: true } as any, + ], + newMatchesCount: 2, + }); + + const updateQuery = createThenableQuery({}); + (supabase.from as jest.Mock).mockReturnValue(updateQuery); + + await useMatchStore.getState().markAllAsSeen(); + + const state = useMatchStore.getState(); + expect(state.matches.every(m => !m.is_new)).toBe(true); + expect(state.newMatchesCount).toBe(0); + }); + + it('does nothing when no new matches', async () => { + useMatchStore.setState({ + matches: [{ id: 'm1', is_new: false } as any], + newMatchesCount: 0, + }); + + await useMatchStore.getState().markAllAsSeen(); + + expect(supabase.from).not.toHaveBeenCalled(); + }); + }); + + describe('addMatch', () => { + it('adds a new match to the beginning', () => { + useMatchStore.setState({ + matches: [{ id: 'm1' } as any], + newMatchesCount: 0, + totalCount: 1, + }); + + useMatchStore.getState().addMatch({ id: 'm2', is_new: true } as any); + + const state = useMatchStore.getState(); + expect(state.matches[0].id).toBe('m2'); + expect(state.matches).toHaveLength(2); + expect(state.newMatchesCount).toBe(1); + expect(state.totalCount).toBe(2); + }); + }); + + describe('updateMatchUnreadCount', () => { + it('increments unread count', () => { + useMatchStore.setState({ + matches: [{ id: 'm1', unreadCount: 1 } as any], + }); + + useMatchStore.getState().updateMatchUnreadCount('m1', 2); + + expect(useMatchStore.getState().matches[0].unreadCount).toBe(3); + }); + + it('decrements unread count but not below zero', () => { + useMatchStore.setState({ + matches: [{ id: 'm1', unreadCount: 1 } as any], + }); + + useMatchStore.getState().updateMatchUnreadCount('m1', -5); + + expect(useMatchStore.getState().matches[0].unreadCount).toBe(0); + }); + + it('handles missing unreadCount', () => { + useMatchStore.setState({ + matches: [{ id: 'm1' } as any], + }); + + useMatchStore.getState().updateMatchUnreadCount('m1', 1); + + expect(useMatchStore.getState().matches[0].unreadCount).toBe(1); + }); + }); + + describe('clearMatches', () => { + it('resets all state to initial values', () => { + useMatchStore.setState({ + matches: [{ id: 'm1' }] as any, + newMatchesCount: 5, + totalCount: 10, + isLoading: true, + error: 'some error', + page: 3, + hasMore: false, + isLoadingMore: true, + archivedMatches: [{ id: 'a1' }] as any, + archivedMatchIds: new Set(['a1']), + showArchived: true, + isLoadingArchived: true, + }); + + useMatchStore.getState().clearMatches(); + + const state = useMatchStore.getState(); + expect(state.matches).toEqual([]); + expect(state.newMatchesCount).toBe(0); + expect(state.totalCount).toBeNull(); + expect(state.isLoading).toBe(false); + expect(state.error).toBeNull(); + expect(state.page).toBe(0); + expect(state.hasMore).toBe(true); + expect(state.isLoadingMore).toBe(false); + expect(state.archivedMatches).toEqual([]); + expect(state.archivedMatchIds.size).toBe(0); + expect(state.showArchived).toBe(false); + expect(state.isLoadingArchived).toBe(false); + }); + }); + + describe('archiveMatch', () => { + it('archives a match with optimistic update', async () => { + useMatchStore.setState({ + matches: [ + { id: 'm1' } as any, + { id: 'm2' } as any, + ], + archivedMatches: [], + archivedMatchIds: new Set(), + totalCount: 2, + }); + + const insertQuery = createThenableQuery({ error: null }); + (supabase.from as jest.Mock).mockReturnValue(insertQuery); + + await useMatchStore.getState().archiveMatch('m1'); + + const state = useMatchStore.getState(); + expect(state.matches).toHaveLength(1); + expect(state.matches[0].id).toBe('m2'); + expect(state.archivedMatches).toHaveLength(1); + expect(state.archivedMatches[0].id).toBe('m1'); + expect(state.archivedMatchIds.has('m1')).toBe(true); + expect(state.totalCount).toBe(1); + }); + + it('does nothing if user not authenticated', async () => { + useAuthStore.setState({ user: null } as any); + + await useMatchStore.getState().archiveMatch('m1'); + + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it('reverts on error', async () => { + useMatchStore.setState({ + matches: [{ id: 'm1' } as any], + archivedMatches: [], + archivedMatchIds: new Set(), + totalCount: 1, + }); + + const insertQuery = createThenableQuery({ error: new Error('DB error') }); + (supabase.from as jest.Mock).mockReturnValue(insertQuery); + + await useMatchStore.getState().archiveMatch('m1'); + + const state = useMatchStore.getState(); + // Should revert + expect(state.matches).toHaveLength(1); + expect(state.archivedMatchIds.has('m1')).toBe(false); + }); + }); + + describe('unarchiveMatch', () => { + it('unarchives a match with optimistic update', async () => { + useMatchStore.setState({ + matches: [], + archivedMatches: [{ id: 'm1' } as any], + archivedMatchIds: new Set(['m1']), + totalCount: 0, + }); + + const deleteQuery = createThenableQuery({ error: null }); + (supabase.from as jest.Mock).mockReturnValue(deleteQuery); + + await useMatchStore.getState().unarchiveMatch('m1'); + + const state = useMatchStore.getState(); + expect(state.matches).toHaveLength(1); + expect(state.matches[0].id).toBe('m1'); + expect(state.archivedMatches).toHaveLength(0); + expect(state.archivedMatchIds.has('m1')).toBe(false); + expect(state.totalCount).toBe(1); + }); + + it('does nothing if user not authenticated', async () => { + useAuthStore.setState({ user: null } as any); + + await useMatchStore.getState().unarchiveMatch('m1'); + + expect(supabase.from).not.toHaveBeenCalled(); + }); + }); + + describe('fetchArchivedMatches', () => { + it('fetches archived matches', async () => { + const archivesQuery = createThenableQuery({ data: [{ match_id: 'm1' }] }); + const matchesQuery = createThenableQuery({ + data: [{ id: 'm1', question_id: 'q1', created_at: '2024-01-01' }], + }); + const unreadQuery = createThenableQuery({ data: [] }); + + (supabase.from as jest.Mock) + .mockReturnValueOnce(archivesQuery) + .mockReturnValueOnce(matchesQuery) + .mockReturnValueOnce(unreadQuery); + + await useMatchStore.getState().fetchArchivedMatches(); + + const state = useMatchStore.getState(); + expect(state.archivedMatches).toHaveLength(1); + expect(state.archivedMatches[0].id).toBe('m1'); + expect(state.isLoadingArchived).toBe(false); + }); + + it('does nothing if no couple', async () => { + useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any); + + await useMatchStore.getState().fetchArchivedMatches(); + + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it('handles empty archives', async () => { + const archivesQuery = createThenableQuery({ data: [] }); + + (supabase.from as jest.Mock).mockReturnValue(archivesQuery); + + await useMatchStore.getState().fetchArchivedMatches(); + + const state = useMatchStore.getState(); + expect(state.archivedMatches).toEqual([]); + expect(state.isLoadingArchived).toBe(false); + }); + }); + + describe('toggleShowArchived', () => { + it('toggles showArchived state', () => { + expect(useMatchStore.getState().showArchived).toBe(false); + + useMatchStore.getState().toggleShowArchived(); + + expect(useMatchStore.getState().showArchived).toBe(true); + + useMatchStore.getState().toggleShowArchived(); + + expect(useMatchStore.getState().showArchived).toBe(false); + }); + + it('fetches archived matches when toggling to show', async () => { + const archivesQuery = createThenableQuery({ data: [] }); + (supabase.from as jest.Mock).mockReturnValue(archivesQuery); + + useMatchStore.getState().toggleShowArchived(); + + // Should trigger fetch + expect(supabase.from).toHaveBeenCalled(); + }); + }); + + describe('isMatchArchived', () => { + it('returns true for archived matches', () => { + useMatchStore.setState({ + archivedMatchIds: new Set(['m1', 'm2']), + }); + + expect(useMatchStore.getState().isMatchArchived('m1')).toBe(true); + expect(useMatchStore.getState().isMatchArchived('m2')).toBe(true); + expect(useMatchStore.getState().isMatchArchived('m3')).toBe(false); + }); }); }); diff --git a/apps/mobile/src/__tests__/stores/responsesStore.test.ts b/apps/mobile/src/__tests__/stores/responsesStore.test.ts new file mode 100644 index 0000000..65d1fa1 --- /dev/null +++ b/apps/mobile/src/__tests__/stores/responsesStore.test.ts @@ -0,0 +1,499 @@ +import { useResponsesStore, groupResponses, ResponseWithQuestion } from '@/store/responsesStore'; +import { useAuthStore } from '@/store/authStore'; +import { useMatchStore } from '@/store/matchStore'; +import { supabase } from '@/lib/supabase'; + +// Mock authErrorHandler +jest.mock('@/lib/authErrorHandler', () => ({ + invokeWithAuthRetry: jest.fn(), +})); + +import { invokeWithAuthRetry } from '@/lib/authErrorHandler'; + +function createThenableQuery(result: any) { + const query: any = { + select: jest.fn(() => query), + eq: jest.fn(() => query), + neq: jest.fn(() => query), + is: jest.fn(() => query), + in: jest.fn(() => query), + order: jest.fn(() => query), + range: jest.fn(() => query), + then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject), + }; + return query; +} + +describe('responsesStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + useResponsesStore.setState({ + responses: [], + isLoading: false, + isLoadingMore: false, + groupBy: 'date', + dateSortOrder: 'newest', + hasMore: true, + page: 0, + totalCount: null, + }); + useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any); + useMatchStore.setState({ matches: [], fetchMatches: jest.fn() } as any); + }); + + describe('fetchResponses', () => { + it('fetches responses with questions and packs on refresh', async () => { + const responses = [ + { + id: 'r1', + question_id: 'q1', + answer: 'yes', + created_at: '2024-01-01T00:00:00.000Z', + question: { + id: 'q1', + text: 'Question 1', + partner_text: null, + intensity: 2, + pack_id: 'p1', + pack: { id: 'p1', name: 'Pack 1', icon: '❤️' }, + }, + }, + ]; + + const countQuery = createThenableQuery({ count: 1 }); + const responsesQuery = createThenableQuery({ data: responses }); + const matchesQuery = createThenableQuery({ data: [{ id: 'm1', question_id: 'q1' }] }); + const partnerQuery = createThenableQuery({ data: [{ question_id: 'q1' }] }); + + (supabase.from as jest.Mock) + .mockReturnValueOnce(countQuery) + .mockReturnValueOnce(responsesQuery) + .mockReturnValueOnce(matchesQuery) + .mockReturnValueOnce(partnerQuery); + + await useResponsesStore.getState().fetchResponses(true); + + const state = useResponsesStore.getState(); + expect(state.responses).toHaveLength(1); + expect(state.responses[0].has_match).toBe(true); + expect(state.responses[0].match_id).toBe('m1'); + expect(state.responses[0].partner_answered).toBe(true); + expect(state.totalCount).toBe(1); + expect(state.isLoading).toBe(false); + }); + + it('does nothing if user is not authenticated', async () => { + useAuthStore.setState({ user: null } as any); + + await useResponsesStore.getState().fetchResponses(true); + + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it('does nothing if couple_id is missing', async () => { + useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any); + + await useResponsesStore.getState().fetchResponses(true); + + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it('prevents concurrent loading', async () => { + useResponsesStore.setState({ isLoading: true }); + + await useResponsesStore.getState().fetchResponses(true); + + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it('handles empty responses correctly', async () => { + const countQuery = createThenableQuery({ count: 0 }); + const responsesQuery = createThenableQuery({ data: [] }); + + (supabase.from as jest.Mock) + .mockReturnValueOnce(countQuery) + .mockReturnValueOnce(responsesQuery); + + await useResponsesStore.getState().fetchResponses(true); + + const state = useResponsesStore.getState(); + expect(state.responses).toEqual([]); + expect(state.hasMore).toBe(false); + expect(state.totalCount).toBe(0); + }); + + it('paginates responses correctly', async () => { + // Set up initial state with some responses + useResponsesStore.setState({ + page: 1, + hasMore: true, + responses: [{ id: 'r0' }] as any, + }); + + const responses = [ + { + id: 'r1', + question_id: 'q1', + answer: 'yes', + created_at: '2024-01-01T00:00:00.000Z', + question: { + id: 'q1', + text: 'Question 1', + pack: { id: 'p1', name: 'Pack 1', icon: '❤️' }, + }, + }, + ]; + + const responsesQuery = createThenableQuery({ data: responses }); + const matchesQuery = createThenableQuery({ data: [] }); + const partnerQuery = createThenableQuery({ data: [] }); + + (supabase.from as jest.Mock) + .mockReturnValueOnce(responsesQuery) + .mockReturnValueOnce(matchesQuery) + .mockReturnValueOnce(partnerQuery); + + await useResponsesStore.getState().fetchResponses(false); + + const state = useResponsesStore.getState(); + // Should append to existing responses + expect(state.responses).toHaveLength(2); + expect(state.page).toBe(2); + }); + + it('handles fetch errors gracefully', async () => { + const countQuery = createThenableQuery({ count: 1 }); + const responsesQuery = createThenableQuery({ data: null, error: new Error('Network error') }); + + (supabase.from as jest.Mock) + .mockReturnValueOnce(countQuery) + .mockReturnValueOnce(responsesQuery); + + await useResponsesStore.getState().fetchResponses(true); + + const state = useResponsesStore.getState(); + expect(state.isLoading).toBe(false); + expect(state.isLoadingMore).toBe(false); + }); + }); + + describe('updateResponse', () => { + it('updates response and refreshes matches on success', async () => { + const fetchMatchesMock = jest.fn(); + useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any); + useResponsesStore.setState({ + responses: [ + { question_id: 'q1', answer: 'maybe', has_match: false } as any, + ], + }); + + (invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({ + data: { success: true, new_match: { id: 'm1' } }, + error: null, + }); + + const result = await useResponsesStore.getState().updateResponse('q1', 'yes'); + + expect(result.success).toBe(true); + expect(result.new_match).toEqual({ id: 'm1' }); + + const state = useResponsesStore.getState(); + expect(state.responses[0].answer).toBe('yes'); + expect(state.responses[0].has_match).toBe(true); + expect(state.responses[0].match_id).toBe('m1'); + expect(fetchMatchesMock).toHaveBeenCalled(); + }); + + it('handles match deletion correctly', async () => { + const fetchMatchesMock = jest.fn(); + useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any); + useResponsesStore.setState({ + responses: [ + { question_id: 'q1', answer: 'yes', has_match: true, match_id: 'm1' } as any, + ], + }); + + (invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({ + data: { success: true, match_deleted: true }, + error: null, + }); + + const result = await useResponsesStore.getState().updateResponse('q1', 'no', true); + + expect(result.success).toBe(true); + expect(result.match_deleted).toBe(true); + + const state = useResponsesStore.getState(); + expect(state.responses[0].answer).toBe('no'); + expect(state.responses[0].has_match).toBe(false); + expect(state.responses[0].match_id).toBeUndefined(); + expect(fetchMatchesMock).toHaveBeenCalled(); + }); + + it('returns requires_confirmation without updating local state', async () => { + useResponsesStore.setState({ + responses: [ + { question_id: 'q1', answer: 'yes', has_match: true } as any, + ], + }); + + (invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({ + data: { success: false, requires_confirmation: true, message_count: 5 }, + error: null, + }); + + const result = await useResponsesStore.getState().updateResponse('q1', 'no'); + + expect(result.requires_confirmation).toBe(true); + expect(result.message_count).toBe(5); + + const state = useResponsesStore.getState(); + // Should not update local state + expect(state.responses[0].answer).toBe('yes'); + }); + + it('handles errors from edge function', async () => { + (invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({ + data: null, + error: { message: 'Server error' }, + }); + + const result = await useResponsesStore.getState().updateResponse('q1', 'yes'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Server error'); + }); + + it('refreshes matches when match_type_updated', async () => { + const fetchMatchesMock = jest.fn(); + useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any); + useResponsesStore.setState({ + responses: [ + { question_id: 'q1', answer: 'maybe', has_match: true, match_id: 'm1' } as any, + ], + }); + + (invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({ + data: { success: true, match_type_updated: true }, + error: null, + }); + + await useResponsesStore.getState().updateResponse('q1', 'yes'); + + expect(fetchMatchesMock).toHaveBeenCalled(); + }); + }); + + describe('setGroupBy', () => { + it('updates groupBy option', () => { + useResponsesStore.getState().setGroupBy('pack'); + expect(useResponsesStore.getState().groupBy).toBe('pack'); + + useResponsesStore.getState().setGroupBy('answer'); + expect(useResponsesStore.getState().groupBy).toBe('answer'); + + useResponsesStore.getState().setGroupBy('date'); + expect(useResponsesStore.getState().groupBy).toBe('date'); + }); + }); + + describe('setDateSortOrder', () => { + it('updates date sort order', () => { + useResponsesStore.getState().setDateSortOrder('oldest'); + expect(useResponsesStore.getState().dateSortOrder).toBe('oldest'); + + useResponsesStore.getState().setDateSortOrder('newest'); + expect(useResponsesStore.getState().dateSortOrder).toBe('newest'); + }); + }); + + describe('toggleDateSortOrder', () => { + it('toggles between newest and oldest', () => { + expect(useResponsesStore.getState().dateSortOrder).toBe('newest'); + + useResponsesStore.getState().toggleDateSortOrder(); + expect(useResponsesStore.getState().dateSortOrder).toBe('oldest'); + + useResponsesStore.getState().toggleDateSortOrder(); + expect(useResponsesStore.getState().dateSortOrder).toBe('newest'); + }); + }); + + describe('clearResponses', () => { + it('resets store to initial state', () => { + useResponsesStore.setState({ + responses: [{ id: 'r1' } as any], + isLoading: true, + groupBy: 'pack', + dateSortOrder: 'oldest', + page: 5, + hasMore: false, + isLoadingMore: true, + totalCount: 10, + }); + + useResponsesStore.getState().clearResponses(); + + const state = useResponsesStore.getState(); + expect(state.responses).toEqual([]); + expect(state.isLoading).toBe(false); + expect(state.groupBy).toBe('date'); + expect(state.dateSortOrder).toBe('newest'); + expect(state.page).toBe(0); + expect(state.hasMore).toBe(true); + expect(state.isLoadingMore).toBe(false); + expect(state.totalCount).toBeNull(); + }); + }); +}); + +describe('groupResponses', () => { + const mockResponses: ResponseWithQuestion[] = [ + { + id: 'r1', + question_id: 'q1', + answer: 'yes', + created_at: '2024-01-15T10:00:00.000Z', + question: { + id: 'q1', + text: 'Question 1', + partner_text: null, + intensity: 2, + pack_id: 'p1', + created_at: '', + pack: { id: 'p1', name: 'Pack A', icon: '❤️' }, + }, + has_match: true, + partner_answered: true, + }, + { + id: 'r2', + question_id: 'q2', + answer: 'maybe', + created_at: '2024-01-15T14:00:00.000Z', + question: { + id: 'q2', + text: 'Question 2', + partner_text: null, + intensity: 3, + pack_id: 'p2', + created_at: '', + pack: { id: 'p2', name: 'Pack B', icon: '💜' }, + }, + has_match: false, + partner_answered: false, + }, + { + id: 'r3', + question_id: 'q3', + answer: 'no', + created_at: '2024-01-14T10:00:00.000Z', + question: { + id: 'q3', + text: 'Question 3', + partner_text: null, + intensity: 1, + pack_id: 'p1', + created_at: '', + pack: { id: 'p1', name: 'Pack A', icon: '❤️' }, + }, + has_match: false, + partner_answered: true, + }, + { + id: 'r4', + question_id: 'q4', + answer: 'yes', + created_at: '2024-01-14T14:00:00.000Z', + question: { + id: 'q4', + text: 'Question 4', + partner_text: null, + intensity: 2, + pack_id: 'p2', + created_at: '', + pack: { id: 'p2', name: 'Pack B', icon: '💜' }, + }, + has_match: true, + partner_answered: true, + }, + ]; + + describe('groupBy pack', () => { + it('groups responses by pack name', () => { + const grouped = groupResponses(mockResponses, 'pack'); + + expect(grouped).toHaveLength(2); + + const packA = grouped.find(g => g.title === 'Pack A'); + const packB = grouped.find(g => g.title === 'Pack B'); + + expect(packA?.data).toHaveLength(2); + expect(packB?.data).toHaveLength(2); + }); + }); + + describe('groupBy answer', () => { + it('groups responses by answer type in order: yes, maybe, no', () => { + const grouped = groupResponses(mockResponses, 'answer'); + + expect(grouped).toHaveLength(3); + expect(grouped[0].title).toBe('Yes'); + expect(grouped[0].data).toHaveLength(2); + expect(grouped[1].title).toBe('Maybe'); + expect(grouped[1].data).toHaveLength(1); + expect(grouped[2].title).toBe('No'); + expect(grouped[2].data).toHaveLength(1); + }); + + it('excludes empty answer groups', () => { + const yesOnlyResponses = mockResponses.filter(r => r.answer === 'yes'); + const grouped = groupResponses(yesOnlyResponses, 'answer'); + + expect(grouped).toHaveLength(1); + expect(grouped[0].title).toBe('Yes'); + }); + }); + + describe('groupBy date', () => { + it('groups responses by date with newest first', () => { + const grouped = groupResponses(mockResponses, 'date', 'newest'); + + expect(grouped).toHaveLength(2); + // January 15 should be first (newer) + expect(grouped[0].title).toContain('January 15'); + expect(grouped[0].data).toHaveLength(2); + expect(grouped[1].title).toContain('January 14'); + expect(grouped[1].data).toHaveLength(2); + }); + + it('groups responses by date with oldest first', () => { + const grouped = groupResponses(mockResponses, 'date', 'oldest'); + + expect(grouped).toHaveLength(2); + // January 14 should be first (older) + expect(grouped[0].title).toContain('January 14'); + expect(grouped[1].title).toContain('January 15'); + }); + + it('sorts responses within each date group', () => { + const grouped = groupResponses(mockResponses, 'date', 'newest'); + + // Within January 15, the response at 14:00 should come first (newest) + const jan15Group = grouped[0]; + expect(new Date(jan15Group.data[0].created_at).getTime()) + .toBeGreaterThan(new Date(jan15Group.data[1].created_at).getTime()); + }); + }); + + describe('default grouping', () => { + it('returns all responses in a single group for unknown groupBy', () => { + // @ts-expect-error Testing unknown groupBy value + const grouped = groupResponses(mockResponses, 'unknown'); + + expect(grouped).toHaveLength(1); + expect(grouped[0].title).toBe('All Responses'); + expect(grouped[0].data).toHaveLength(4); + }); + }); +}); diff --git a/apps/mobile/src/__tests__/stores/subscriptionStore.test.ts b/apps/mobile/src/__tests__/stores/subscriptionStore.test.ts index 7040768..cd1460d 100644 --- a/apps/mobile/src/__tests__/stores/subscriptionStore.test.ts +++ b/apps/mobile/src/__tests__/stores/subscriptionStore.test.ts @@ -1,11 +1,12 @@ -jest.mock('../../lib/revenuecat', () => { - const service = { +jest.mock('../../lib/revenuecat', () => ({ + __esModule: true, + default: { isAvailable: jest.fn(() => true), isInitialized: jest.fn(() => true), initialize: jest.fn(async () => {}), login: jest.fn(async () => null), logout: jest.fn(async () => {}), - addCustomerInfoUpdateListener: jest.fn(() => null), + addCustomerInfoUpdateListener: jest.fn(() => () => {}), getOfferingsDebug: jest.fn(async () => ({ current: { identifier: 'default', availablePackages: [{ identifier: 'monthly', packageType: 'MONTHLY', product: { price: 1, priceString: '$1', currencyCode: 'USD' } }] }, availableOfferings: ['default'], @@ -15,24 +16,33 @@ jest.mock('../../lib/revenuecat', () => { restorePurchases: jest.fn(async () => ({ entitlements: { active: {} } })), getCustomerInfo: jest.fn(async () => ({ entitlements: { active: {} } })), parseCustomerInfo: jest.fn(() => ({ isProUser: false, activeSubscription: null, expirationDate: null, willRenew: false })), - }; - - return { - __esModule: true, - default: service, - revenueCatService: service, - SubscriptionState: {}, - PurchasesPackage: {}, - PurchasesOffering: {}, - }; -}); + }, + get revenueCatService() { + return this.default; + }, + SubscriptionState: {}, + PurchasesPackage: {}, + PurchasesOffering: {}, +})); import { useSubscriptionStore } from '@/store/subscriptionStore'; import { useAuthStore } from '@/store/authStore'; +import { supabase } from '@/lib/supabase'; +import revenueCatService from '../../lib/revenuecat'; describe('subscriptionStore', () => { beforeEach(() => { jest.clearAllMocks(); + // Reset mock implementations to defaults + (revenueCatService.isAvailable as jest.Mock).mockReturnValue(true); + (revenueCatService.isInitialized as jest.Mock).mockReturnValue(true); + (revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({ + isProUser: false, + activeSubscription: null, + expirationDate: null, + willRenew: false, + }); + useSubscriptionStore.setState({ subscription: { isProUser: false, activeSubscription: null, expirationDate: null, willRenew: false }, offerings: null, @@ -40,24 +50,236 @@ describe('subscriptionStore', () => { isPurchasing: false, error: null, } as any); - useAuthStore.setState({ user: { id: 'me', is_premium: false } } as any); + useAuthStore.setState({ + user: { id: 'me', is_premium: false }, + fetchUser: jest.fn(), + } as any); }); - it('fetchOfferings stores offering on success', async () => { - await useSubscriptionStore.getState().fetchOfferings(); + describe('initializeRevenueCat', () => { + it('skips initialization if RevenueCat not available', async () => { + (revenueCatService.isAvailable as jest.Mock).mockReturnValue(false); - const state = useSubscriptionStore.getState(); - expect(state.offerings).toBeTruthy(); - expect(state.isLoadingOfferings).toBe(false); - expect(state.error).toBeNull(); + await useSubscriptionStore.getState().initializeRevenueCat('user1'); + + expect(revenueCatService.initialize).not.toHaveBeenCalled(); + }); + + it('initializes and logs in when available', async () => { + await useSubscriptionStore.getState().initializeRevenueCat('user1'); + + expect(revenueCatService.initialize).toHaveBeenCalledWith('user1'); + expect(revenueCatService.login).toHaveBeenCalledWith('user1'); + expect(revenueCatService.addCustomerInfoUpdateListener).toHaveBeenCalled(); + }); + + it('handles initialization errors gracefully', async () => { + (revenueCatService.initialize as jest.Mock).mockRejectedValueOnce(new Error('Init failed')); + + // Should not throw + await useSubscriptionStore.getState().initializeRevenueCat('user1'); + + // Error is logged but not set in state + expect(useSubscriptionStore.getState().error).toBeNull(); + }); }); - it('clearSubscription resets state', () => { - useSubscriptionStore.setState({ offerings: { identifier: 'x', availablePackages: [] } } as any); - useSubscriptionStore.getState().clearSubscription(); + describe('fetchOfferings', () => { + it('stores offering on success', async () => { + await useSubscriptionStore.getState().fetchOfferings(); - const state = useSubscriptionStore.getState(); - expect(state.offerings).toBeNull(); - expect(state.subscription.isProUser).toBe(false); + const state = useSubscriptionStore.getState(); + expect(state.offerings).toBeTruthy(); + expect(state.isLoadingOfferings).toBe(false); + expect(state.error).toBeNull(); + }); + + it('sets error if RevenueCat not initialized', async () => { + (revenueCatService.isInitialized as jest.Mock).mockReturnValue(false); + + await useSubscriptionStore.getState().fetchOfferings(); + + const state = useSubscriptionStore.getState(); + expect(state.error).toBe('Subscriptions not available'); + expect(state.offerings).toBeNull(); + expect(state.isLoadingOfferings).toBe(false); + }); + + it('handles offering fetch error', async () => { + (revenueCatService.getOfferingsDebug as jest.Mock).mockResolvedValueOnce({ + error: 'Failed to fetch', + }); + + await useSubscriptionStore.getState().fetchOfferings(); + + const state = useSubscriptionStore.getState(); + expect(state.error).toBe('Failed to fetch'); + expect(state.offerings).toBeNull(); + }); + + it('handles no current offering', async () => { + (revenueCatService.getOfferingsDebug as jest.Mock).mockResolvedValueOnce({ + current: null, + availableOfferings: ['test'], + }); + + await useSubscriptionStore.getState().fetchOfferings(); + + const state = useSubscriptionStore.getState(); + expect(state.error).toContain('No current offering'); + }); + + it('handles empty packages', async () => { + (revenueCatService.getOfferingsDebug as jest.Mock).mockResolvedValueOnce({ + current: { identifier: 'default', availablePackages: [] }, + }); + + await useSubscriptionStore.getState().fetchOfferings(); + + const state = useSubscriptionStore.getState(); + expect(state.error).toContain('No packages in offering'); + }); + + it('handles exception during fetch', async () => { + (revenueCatService.getOfferingsDebug as jest.Mock).mockRejectedValueOnce(new Error('Network error')); + + await useSubscriptionStore.getState().fetchOfferings(); + + const state = useSubscriptionStore.getState(); + expect(state.error).toContain('Error: Network error'); + expect(state.isLoadingOfferings).toBe(false); + }); + }); + + describe('purchasePackage', () => { + it('purchases package successfully', async () => { + const mockPackage = { identifier: 'monthly' }; + (revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({ + isProUser: true, + activeSubscription: 'monthly', + expirationDate: '2025-01-01', + willRenew: true, + }); + + const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any); + + expect(result).toBe(true); + const state = useSubscriptionStore.getState(); + expect(state.subscription.isProUser).toBe(true); + expect(state.isPurchasing).toBe(false); + expect(useAuthStore.getState().fetchUser).toHaveBeenCalled(); + }); + + it('handles cancelled purchase', async () => { + const mockPackage = { identifier: 'monthly' }; + (revenueCatService.purchasePackage as jest.Mock).mockRejectedValueOnce( + new Error('Purchase cancelled') + ); + + const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any); + + expect(result).toBe(false); + const state = useSubscriptionStore.getState(); + expect(state.error).toBe('Purchase was cancelled'); + expect(state.isPurchasing).toBe(false); + }); + + it('handles other purchase errors', async () => { + const mockPackage = { identifier: 'monthly' }; + (revenueCatService.purchasePackage as jest.Mock).mockRejectedValueOnce( + new Error('Payment failed') + ); + + const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any); + + expect(result).toBe(false); + const state = useSubscriptionStore.getState(); + expect(state.error).toBe('Purchase failed. Please try again.'); + }); + }); + + describe('restorePurchases', () => { + it('restores purchases successfully when user has pro', async () => { + (revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({ + isProUser: true, + activeSubscription: 'yearly', + expirationDate: '2025-12-01', + willRenew: true, + }); + + const result = await useSubscriptionStore.getState().restorePurchases(); + + expect(result).toBe(true); + const state = useSubscriptionStore.getState(); + expect(state.subscription.isProUser).toBe(true); + expect(state.isPurchasing).toBe(false); + }); + + it('returns false when no purchases to restore', async () => { + const result = await useSubscriptionStore.getState().restorePurchases(); + + expect(result).toBe(false); + }); + + it('handles restore error', async () => { + (revenueCatService.restorePurchases as jest.Mock).mockRejectedValueOnce( + new Error('Restore failed') + ); + + const result = await useSubscriptionStore.getState().restorePurchases(); + + expect(result).toBe(false); + const state = useSubscriptionStore.getState(); + expect(state.error).toBe('Failed to restore purchases'); + }); + }); + + describe('refreshSubscriptionStatus', () => { + it('refreshes subscription status', async () => { + (revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({ + isProUser: true, + activeSubscription: 'monthly', + expirationDate: '2025-01-01', + willRenew: true, + }); + + await useSubscriptionStore.getState().refreshSubscriptionStatus(); + + const state = useSubscriptionStore.getState(); + expect(state.subscription.isProUser).toBe(true); + }); + + it('handles refresh errors gracefully', async () => { + (revenueCatService.getCustomerInfo as jest.Mock).mockRejectedValueOnce( + new Error('Network error') + ); + + // Should not throw + await useSubscriptionStore.getState().refreshSubscriptionStatus(); + }); + }); + + describe('clearSubscription', () => { + it('resets state and logs out', () => { + useSubscriptionStore.setState({ + subscription: { + isProUser: true, + activeSubscription: 'monthly', + expirationDate: new Date('2025-01-01'), + willRenew: true, + }, + offerings: { identifier: 'x', availablePackages: [] } as any, + error: 'some error', + }); + + useSubscriptionStore.getState().clearSubscription(); + + const state = useSubscriptionStore.getState(); + expect(state.offerings).toBeNull(); + expect(state.subscription.isProUser).toBe(false); + expect(state.subscription.activeSubscription).toBeNull(); + expect(state.error).toBeNull(); + expect(revenueCatService.logout).toHaveBeenCalled(); + }); }); }); diff --git a/apps/mobile/src/components/MatchNotificationModal.tsx b/apps/mobile/src/components/MatchNotificationModal.tsx new file mode 100644 index 0000000..15e7182 --- /dev/null +++ b/apps/mobile/src/components/MatchNotificationModal.tsx @@ -0,0 +1,526 @@ +/** + * MatchNotificationModal - Premium celebration modal for new matches + * + * Features: + * - Glass-morphism styling following boutique design aesthetic + * - Animated floating hearts celebration effect + * - Shows what each partner answered (You / Partner) + * - Uses brand gradients and premium styling + */ +import React, { useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + Pressable, + Animated, +} from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { BlurView } from 'expo-blur'; +import { Ionicons } from '@expo/vector-icons'; +import { colors, gradients, spacing, radius, typography, shadows } from '../theme'; +import { DecorativeSeparator } from './ui'; +import type { MatchType, AnswerType } from '../types'; + +export interface MatchNotificationData { + id: string; + match_type: MatchType; + question: { + id: string; + text: string; + partner_text?: string | null; + }; + userResponse?: AnswerType; + partnerResponse?: AnswerType; + partnerName?: string | null; +} + +interface Props { + visible: boolean; + match: MatchNotificationData | null; + onDismiss: () => void; + onChat: () => void; +} + +// Helper to get response styling +const getResponseStyle = (response: AnswerType, isPartner: boolean = false) => { + switch (response) { + case 'yes': + return { + bg: isPartner ? 'rgba(46, 204, 113, 0.15)' : 'rgba(233, 69, 96, 0.15)', + border: isPartner ? 'rgba(46, 204, 113, 0.3)' : 'rgba(233, 69, 96, 0.3)', + color: isPartner ? colors.success : colors.primary, + text: 'YES', + icon: 'heart' as const, + }; + case 'maybe': + return { + bg: 'rgba(243, 156, 18, 0.15)', + border: 'rgba(243, 156, 18, 0.3)', + color: colors.warning, + text: 'MAYBE', + icon: 'help-circle' as const, + }; + default: + return { + bg: 'rgba(255, 255, 255, 0.1)', + border: 'rgba(255, 255, 255, 0.2)', + color: colors.textSecondary, + text: response.toUpperCase(), + icon: 'ellipse' as const, + }; + } +}; + +// Compact response badge (for two-part questions) +const ResponseBadge: React.FC<{ + response: AnswerType; + isPartner?: boolean; +}> = ({ response, isPartner = false }) => { + const style = getResponseStyle(response, isPartner); + + return ( + + + + {style.text} + + + ); +}; + +export const MatchNotificationModal: React.FC = ({ + visible, + match, + onDismiss, + onChat, +}) => { + const modalScale = useRef(new Animated.Value(0.8)).current; + const modalOpacity = useRef(new Animated.Value(0)).current; + const overlayOpacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (visible) { + // Reset animations + modalScale.setValue(0.8); + modalOpacity.setValue(0); + overlayOpacity.setValue(0); + + // Animate in + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + useNativeDriver: true, + }), + Animated.spring(modalScale, { + toValue: 1, + tension: 80, + friction: 10, + useNativeDriver: true, + }), + Animated.timing(modalOpacity, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }), + ]).start(); + } + }, [visible]); + + const handleDismiss = () => { + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 0, + duration: 200, + useNativeDriver: true, + }), + Animated.timing(modalScale, { + toValue: 0.8, + duration: 200, + useNativeDriver: true, + }), + Animated.timing(modalOpacity, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }), + ]).start(() => onDismiss()); + }; + + const handleChat = () => { + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 0, + duration: 200, + useNativeDriver: true, + }), + Animated.timing(modalScale, { + toValue: 0.8, + duration: 200, + useNativeDriver: true, + }), + Animated.timing(modalOpacity, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }), + ]).start(() => onChat()); + }; + + if (!visible || !match) return null; + + // Determine responses based on match_type if not explicitly provided + const getUserResponse = (): AnswerType => { + if (match.userResponse) return match.userResponse; + // Infer from match_type (user typically triggers the match) + if (match.match_type === 'yes_yes') return 'yes'; + if (match.match_type === 'maybe_maybe') return 'maybe'; + // yes_maybe - user could be either + return 'yes'; + }; + + const getPartnerResponse = (): AnswerType => { + if (match.partnerResponse) return match.partnerResponse; + // Infer from match_type + if (match.match_type === 'yes_yes') return 'yes'; + if (match.match_type === 'maybe_maybe') return 'maybe'; + // yes_maybe - partner could be either + return 'maybe'; + }; + + const userResponse = getUserResponse(); + const partnerResponse = getPartnerResponse(); + + // Get match type styling + const getMatchTypeLabel = () => { + switch (match.match_type) { + case 'yes_yes': + return { text: 'Perfect Match', color: colors.primary }; + case 'yes_maybe': + return { text: 'Curious Match', color: colors.warning }; + case 'maybe_maybe': + return { text: 'Exploring Together', color: colors.premium.rose }; + default: + return { text: 'Match', color: colors.primary }; + } + }; + + const matchTypeInfo = getMatchTypeLabel(); + + // Use partner's name or fallback to "Partner" + const partnerDisplayName = match.partnerName || 'Partner'; + + return ( + + {/* Background press to dismiss */} + + + {/* Modal card with glow */} + + {/* Outer glow effect */} + + + + {/* Glass background */} + + + + + {/* Content */} + + {/* Title section */} + It's a Match! + + {/* Match type badge */} + + + {matchTypeInfo.text} + + + + + + {/* Question text and responses */} + {match.question.partner_text ? ( + // Two-part question - show what each person saw + + + + You said + + + "{match.question.text}" + + + + + + + + + + + + + {partnerDisplayName} said + + + "{match.question.partner_text}" + + + ) : ( + // Single question - show shared text with inline responses + + "{match.question.text}" + + You said + + {userResponse.toUpperCase()} + + · {partnerDisplayName} said + + {partnerResponse.toUpperCase()} + + + + )} + + + + {/* Action buttons */} + + + + + Chat + + + + + Continue + + + + + {/* Border */} + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.75)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 9999, + }, + glowContainer: { + width: '88%', + maxWidth: 360, + alignItems: 'center', + justifyContent: 'center', + }, + glowOuter: { + position: 'absolute', + top: -3, + left: -3, + right: -3, + bottom: -3, + borderRadius: radius.xl + 3, + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: 'rgba(233, 69, 96, 0.15)', + shadowColor: colors.primary, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.2, + shadowRadius: 20, + elevation: 8, + }, + modalContainer: { + width: '100%', + borderRadius: radius.xl, + overflow: 'hidden', + }, + blurContainer: { + ...StyleSheet.absoluteFillObject, + }, + gradientOverlay: { + ...StyleSheet.absoluteFillObject, + }, + content: { + padding: spacing.xl, + paddingTop: spacing.lg, + alignItems: 'center', + }, + title: { + ...typography.title2, + color: colors.text, + textAlign: 'center', + marginBottom: spacing.sm, + }, + matchTypeBadge: { + backgroundColor: 'rgba(255, 255, 255, 0.05)', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radius.full, + borderWidth: 1, + marginBottom: spacing.lg, + }, + matchTypeText: { + ...typography.caption1, + fontWeight: '600', + letterSpacing: 1, + }, + questionContainer: { + alignItems: 'center', + }, + questionText: { + ...typography.body, + color: colors.text, + textAlign: 'center', + fontStyle: 'italic', + lineHeight: 24, + paddingHorizontal: spacing.sm, + marginBottom: spacing.md, + }, + responsesInline: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + alignItems: 'center', + }, + responseInlineText: { + ...typography.caption1, + color: colors.textSecondary, + }, + responseInlineAnswer: { + ...typography.caption1, + fontWeight: '700', + }, + responseBadgeCompact: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radius.md, + borderWidth: 1, + }, + responseBadgeTextCompact: { + ...typography.caption1, + fontWeight: '600', + letterSpacing: 0.5, + }, + // Two-part question styles + twoPartContainer: { + width: '100%', + marginBottom: spacing.md, + }, + responseSection: { + alignItems: 'center', + paddingHorizontal: spacing.sm, + }, + responseLabelRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginBottom: spacing.xs, + }, + responsePersonLabel: { + ...typography.caption1, + color: colors.textSecondary, + fontWeight: '500', + }, + questionTextSmall: { + ...typography.subhead, + color: colors.text, + textAlign: 'center', + fontStyle: 'italic', + lineHeight: 20, + }, + matchConnector: { + flexDirection: 'row', + alignItems: 'center', + marginVertical: spacing.md, + paddingHorizontal: spacing.xl, + }, + connectorLine: { + flex: 1, + height: 1, + backgroundColor: 'rgba(233, 69, 96, 0.2)', + }, + connectorHeart: { + marginHorizontal: spacing.sm, + opacity: 0.8, + }, + buttonRow: { + flexDirection: 'row', + gap: spacing.md, + width: '100%', + }, + chatButton: { + flex: 1, + borderRadius: radius.lg, + overflow: 'hidden', + ...shadows.md, + }, + chatButtonGradient: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingVertical: spacing.md, + }, + chatButtonText: { + ...typography.headline, + color: colors.text, + }, + dismissButton: { + flex: 1, + borderRadius: radius.lg, + backgroundColor: colors.glass.background, + borderWidth: 1, + borderColor: colors.glass.border, + paddingVertical: spacing.md, + alignItems: 'center', + justifyContent: 'center', + }, + dismissButtonText: { + ...typography.headline, + color: colors.textSecondary, + }, + border: { + ...StyleSheet.absoluteFillObject, + borderRadius: radius.xl, + borderWidth: 1, + borderColor: 'rgba(233, 69, 96, 0.2)', + }, +}); diff --git a/apps/mobile/src/components/matches/SwipeableMatchItem.tsx b/apps/mobile/src/components/matches/SwipeableMatchItem.tsx new file mode 100644 index 0000000..8b2eb30 --- /dev/null +++ b/apps/mobile/src/components/matches/SwipeableMatchItem.tsx @@ -0,0 +1,173 @@ +import React from "react"; +import { View, Text, StyleSheet, Platform } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import Animated, { + useSharedValue, + useAnimatedStyle, + withSpring, + runOnJS, + interpolate, + Extrapolation, +} from "react-native-reanimated"; +import { Ionicons } from "@expo/vector-icons"; +import { colors, spacing, radius, typography } from "../../theme"; + +const SWIPE_THRESHOLD = 80; +const ACCENT = colors.premium.gold; + +// 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); +}; + +interface SwipeableMatchItemProps { + children: React.ReactNode; + onArchive: () => void; + isArchived?: boolean; + enabled?: boolean; +} + +export function SwipeableMatchItem({ + children, + onArchive, + isArchived = false, + enabled = true, +}: SwipeableMatchItemProps) { + const translateX = useSharedValue(0); + const hasTriggeredHaptic = useSharedValue(false); + + const panGesture = Gesture.Pan() + .enabled(enabled) + .activeOffsetX([-20, 20]) + .onUpdate((event) => { + // Only allow left swipe (negative X) for archive + translateX.value = Math.min(0, event.translationX); + + // Trigger haptic when crossing threshold + if (translateX.value < -SWIPE_THRESHOLD && !hasTriggeredHaptic.value) { + hasTriggeredHaptic.value = true; + runOnJS(triggerHaptic)('medium'); + } else if (translateX.value >= -SWIPE_THRESHOLD) { + hasTriggeredHaptic.value = false; + } + }) + .onEnd(() => { + if (translateX.value < -SWIPE_THRESHOLD) { + // Animate out then trigger archive + translateX.value = withSpring(-200, { damping: 15 }, (finished) => { + if (finished) { + runOnJS(onArchive)(); + } + }); + } else { + // Spring back to center + translateX.value = withSpring(0, { damping: 15 }); + } + hasTriggeredHaptic.value = false; + }); + + const containerStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })); + + const actionStyle = useAnimatedStyle(() => ({ + opacity: interpolate( + translateX.value, + [-SWIPE_THRESHOLD, -20, 0], + [1, 0.5, 0], + Extrapolation.CLAMP + ), + transform: [ + { + scale: interpolate( + translateX.value, + [-SWIPE_THRESHOLD, 0], + [1, 0.8], + Extrapolation.CLAMP + ), + }, + ], + })); + + const actionIconStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateX: interpolate( + translateX.value, + [-SWIPE_THRESHOLD * 1.5, -SWIPE_THRESHOLD, 0], + [0, 10, 30], + Extrapolation.CLAMP + ), + }, + ], + })); + + return ( + + {/* Archive action revealed on swipe */} + + + + + + + {isArchived ? "Restore" : "Archive"} + + + + + + + {children} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + position: "relative", + overflow: "hidden", + }, + actionContainer: { + position: "absolute", + right: spacing.md, + top: 0, + bottom: 0, + justifyContent: "center", + alignItems: "flex-end", + paddingRight: spacing.sm, + }, + actionContent: { + alignItems: "center", + gap: spacing.xs, + }, + iconContainer: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: `rgba(212, 175, 55, 0.15)`, + borderWidth: 1, + borderColor: `rgba(212, 175, 55, 0.3)`, + justifyContent: "center", + alignItems: "center", + }, + actionText: { + ...typography.caption2, + color: ACCENT, + fontWeight: "600", + letterSpacing: 0.5, + }, +}); diff --git a/apps/mobile/src/components/matches/index.ts b/apps/mobile/src/components/matches/index.ts new file mode 100644 index 0000000..50fb74b --- /dev/null +++ b/apps/mobile/src/components/matches/index.ts @@ -0,0 +1 @@ +export { SwipeableMatchItem } from './SwipeableMatchItem'; diff --git a/apps/mobile/src/features/chat/ChatScreen.tsx b/apps/mobile/src/features/chat/ChatScreen.tsx index 4bd45f1..8479e7a 100644 --- a/apps/mobile/src/features/chat/ChatScreen.tsx +++ b/apps/mobile/src/features/chat/ChatScreen.tsx @@ -1,21 +1,18 @@ import { useState, useEffect, useCallback } from "react"; -import { View, Text, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Modal, Dimensions, Alert } from "react-native"; -import { Image } from "expo-image"; -import { Video, ResizeMode } from "expo-av"; +import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useFocusEffect, useIsFocused } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Ionicons } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; -import Animated, { FadeInDown, useAnimatedStyle } from "react-native-reanimated"; +import Animated, { FadeInDown } from "react-native-reanimated"; -import { InputBar, ChatHeader, ChatMessages, ReportMessageSheet } from "./components"; +import { InputBar, ChatHeader, ChatMessages, ReportMessageSheet, ChatSettingsSheet, FullScreenImageModal, FullScreenVideoModal } from "./components"; import { useMediaUpload } from "./hooks/useMediaUpload"; import { useMessageActions } from "./hooks/useMessageActions"; -import type { ReportReason } from "./types"; +import type { ReportReason, Match } from "./types"; import { supabase } from "../../lib/supabase"; -import { useAuthStore, useMessageStore } from "../../store"; +import { useAuthStore, useMessageStore, useMatchStore } from "../../store"; import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useMediaSaver } from "../../hooks"; import { Database } from "../../types/supabase"; import { GradientBackground } from "../../components/ui"; @@ -25,7 +22,6 @@ import { Events } from "../../lib/analytics"; type Message = Database["public"]["Tables"]["messages"]["Row"]; // Premium color palette for Chat -const ACCENT = colors.premium.gold; const ROSE_RGBA = 'rgba(232, 164, 174, '; const ACCENT_RGBA = 'rgba(212, 175, 55, '; @@ -36,8 +32,10 @@ export const ChatScreen: React.FC = () => { const insets = useSafeAreaInsets(); const { user, partner } = useAuthStore(); const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore(); - const [match, setMatch] = useState(null); + const { archiveMatch, unarchiveMatch, isMatchArchived } = useMatchStore(); + const [match, setMatch] = useState(null); const [inputText, setInputText] = useState(""); + const [showSettingsSheet, setShowSettingsSheet] = useState(false); const { uploading, uploadStatus, uploadMedia } = useMediaUpload(matchId, user?.id); const { saveMedia, saving: mediaSaving } = useMediaSaver(); const [fullScreenImage, setFullScreenImage] = useState(null); @@ -81,6 +79,62 @@ export const ChatScreen: React.FC = () => { onReport: (message) => setReportingMessage(message), }); + // Memoized navigation callbacks + const handleBack = useCallback(() => { + router.push('/(app)/matches'); + }, [router]); + + const handleOpenSettings = useCallback(() => { + setShowSettingsSheet(true); + }, []); + + const handleCloseSettings = useCallback(() => { + setShowSettingsSheet(false); + }, []); + + // Memoized modal callbacks + const handleCloseImage = useCallback(() => { + setFullScreenImage(null); + }, []); + + const handleSaveImage = useCallback(() => { + if (fullScreenImage) { + saveMedia(fullScreenImage, 'image'); + } + }, [fullScreenImage, saveMedia]); + + const handleImageLoadStart = useCallback(() => { + setFullScreenImageLoading(true); + }, []); + + const handleImageLoadEnd = useCallback(() => { + setFullScreenImageLoading(false); + }, []); + + const handleCloseVideo = useCallback(() => { + setFullScreenVideo(null); + }, []); + + const handleSaveVideo = useCallback(() => { + if (fullScreenVideo) { + saveMedia(fullScreenVideo, 'video'); + } + }, [fullScreenVideo, saveMedia]); + + const handleCloseReport = useCallback(() => { + setReportingMessage(null); + }, []); + + // Memoized archive/unarchive callbacks + const handleArchive = useCallback(async () => { + await archiveMatch(matchId); + router.push('/(app)/matches'); + }, [archiveMatch, matchId, router]); + + const handleUnarchive = useCallback(async () => { + await unarchiveMatch(matchId); + }, [unarchiveMatch, matchId]); + // Handle long press on message to show options const handleMessageLongPress = useCallback((message: Message, isMe: boolean) => { // Don't show options for already deleted messages @@ -89,7 +143,7 @@ export const ChatScreen: React.FC = () => { }, [showDeleteOptions]); // Handle report submission - const handleSubmitReport = async (reason: ReportReason) => { + const handleSubmitReport = useCallback(async (reason: ReportReason) => { if (!reportingMessage || !user?.id) return; try { @@ -127,7 +181,7 @@ export const ChatScreen: React.FC = () => { Alert.alert('Error', 'Failed to submit report'); setReportingMessage(null); } - }; + }, [reportingMessage, user?.id, deleteForSelf, setMessages]); // Track active chat to prevent notifications for current chat useFocusEffect( @@ -169,15 +223,15 @@ export const ChatScreen: React.FC = () => { fetchMatch(); }, [matchId]); - const handleTyping = (text: string) => { + const handleTyping = useCallback((text: string) => { setInputText(text); if (text.length > 0) { sendTypingEvent(); } - }; + }, [sendTypingEvent]); - const handleSend = async () => { + const handleSend = useCallback(async () => { if (!inputText.trim()) return; if (!user) return; @@ -204,30 +258,30 @@ export const ChatScreen: React.FC = () => { Alert.alert("Delivery Failed", "Couldn't send message. Please check your connection."); setInputText(content); } - }; + }, [inputText, user, matchId]); - const handlePickMedia = async () => { + const handlePickMedia = useCallback(async () => { const result = await pickMedia(); if (result) { uploadMedia(result.uri, result.mediaType); } - }; + }, [pickMedia, uploadMedia]); - const handleTakePhoto = async () => { + const handleTakePhoto = useCallback(async () => { const result = await takePhoto(); if (result) { uploadMedia(result.uri, 'image'); } - }; + }, [takePhoto, uploadMedia]); - const handleRecordVideo = async () => { + const handleRecordVideo = useCallback(async () => { const result = await recordVideo(); if (result) { uploadMedia(result.uri, 'video'); } - }; + }, [recordVideo, uploadMedia]); - const revealMessage = async (messageId: string) => { + const revealMessage = useCallback(async (messageId: string) => { const message = messages.find(m => m.id === messageId); const now = new Date(); // Set expiration 30 days from now for videos only @@ -250,7 +304,7 @@ export const ChatScreen: React.FC = () => { media_expires_at: expiresAt }) .eq("id", messageId); - }; + }, [messages, setMessages]); return ( @@ -280,10 +334,9 @@ export const ChatScreen: React.FC = () => { router.push('/(app)/matches')} + onBack={handleBack} + onSettingsPress={handleOpenSettings} /> @@ -327,124 +380,41 @@ export const ChatScreen: React.FC = () => { {/* Full Screen Image Modal */} - setFullScreenImage(null)} - > - - fullScreenImage && saveMedia(fullScreenImage, 'image')} - activeOpacity={0.8} - disabled={mediaSaving} - > - - {mediaSaving ? ( - - ) : ( - - )} - - - - setFullScreenImage(null)} - activeOpacity={0.8} - > - - - - - {fullScreenImageLoading && ( - - )} - {fullScreenImage && ( - setFullScreenImageLoading(true)} - onLoadEnd={() => setFullScreenImageLoading(false)} - /> - )} - - + loading={fullScreenImageLoading} + saving={mediaSaving} + onClose={handleCloseImage} + onSave={handleSaveImage} + onLoadStart={handleImageLoadStart} + onLoadEnd={handleImageLoadEnd} + /> {/* Full Screen Video Modal */} - setFullScreenVideo(null)} - > - - fullScreenVideo && saveMedia(fullScreenVideo, 'video')} - activeOpacity={0.8} - disabled={mediaSaving} - > - - {mediaSaving ? ( - - ) : ( - - )} - - - - setFullScreenVideo(null)} - activeOpacity={0.8} - > - - - - - {fullScreenVideo && ( - - + saving={mediaSaving} + onClose={handleCloseVideo} + onSave={handleSaveVideo} + /> {/* Report Message Sheet */} setReportingMessage(null)} + onClose={handleCloseReport} onSubmit={handleSubmitReport} /> + + {/* Chat Settings Sheet */} + ); }; @@ -473,43 +443,6 @@ const styles = StyleSheet.create({ height: '100%', borderRadius: 140, }, - // Full Screen Modal - fullScreenOverlay: { - flex: 1, - backgroundColor: "rgba(0, 0, 0, 0.95)", - justifyContent: "center", - alignItems: "center", - }, - fullScreenCloseButton: { - position: "absolute", - top: 50, - right: 20, - zIndex: 10, - }, - fullScreenSaveButton: { - position: "absolute", - top: 50, - left: 20, - zIndex: 10, - }, - fullScreenCloseGradient: { - width: 44, - height: 44, - borderRadius: 22, - justifyContent: 'center', - alignItems: 'center', - }, - fullScreenImage: { - width: Dimensions.get("window").width, - height: Dimensions.get("window").height * 0.8, - }, - fullScreenVideo: { - width: Dimensions.get("window").width, - height: Dimensions.get("window").height * 0.8, - }, - fullScreenSpinner: { - position: "absolute", - }, inputWrapper: { overflow: 'hidden', }, diff --git a/apps/mobile/src/features/chat/components/ChatHeader.tsx b/apps/mobile/src/features/chat/components/ChatHeader.tsx index c121eff..2e798fe 100644 --- a/apps/mobile/src/features/chat/components/ChatHeader.tsx +++ b/apps/mobile/src/features/chat/components/ChatHeader.tsx @@ -15,16 +15,16 @@ const ACCENT_RGBA = 'rgba(212, 175, 55, '; interface ChatHeaderProps { partner: Profile | null; - user: { id: string } | null; - match: any; // Kept for compatibility but unused insets: EdgeInsets; onBack: () => void; + onSettingsPress?: () => void; } -export const ChatHeader: React.FC = ({ +const ChatHeaderComponent: React.FC = ({ partner, insets, onBack, + onSettingsPress, }) => { return ( = ({ onPress={onBack} style={styles.backButton} activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel="Go back to matches" > @@ -86,7 +88,21 @@ export const ChatHeader: React.FC = ({ - + {onSettingsPress ? ( + + + + + + ) : ( + + )} {/* Bottom border */} @@ -94,6 +110,9 @@ export const ChatHeader: React.FC = ({ ); }; +// Wrap with React.memo for performance +export const ChatHeader = React.memo(ChatHeaderComponent); + const styles = StyleSheet.create({ header: { flexDirection: 'row', @@ -179,6 +198,12 @@ const styles = StyleSheet.create({ headerSpacer: { width: 40, }, + settingsButton: { + width: 40, + height: 40, + justifyContent: 'center', + alignItems: 'center', + }, headerBorderBottom: { position: 'absolute', bottom: 0, diff --git a/apps/mobile/src/features/chat/components/ChatMessages.tsx b/apps/mobile/src/features/chat/components/ChatMessages.tsx index 51aeb3b..dab027a 100644 --- a/apps/mobile/src/features/chat/components/ChatMessages.tsx +++ b/apps/mobile/src/features/chat/components/ChatMessages.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useRef, useCallback, useMemo } from 'react'; import { View, Text, FlatList, StyleSheet } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import Animated, { FadeIn } from 'react-native-reanimated'; @@ -12,14 +12,14 @@ import { MatchCard } from './MatchCard'; import { DecorativeSeparator } from '../../../components/ui'; import { colors, spacing, typography } from '../../../theme'; import { Database } from '../../../types/supabase'; -import { UploadStatus } from '../types'; +import { UploadStatus, Match } from '../types'; type Message = Database['public']['Tables']['messages']['Row']; interface ChatMessagesProps { messages: Message[]; userId: string | undefined; - match: any; + match: Match | null; uploadStatus: UploadStatus | null; partnerTyping: boolean; onImagePress: (uri: string) => void; @@ -43,7 +43,7 @@ function isSameDay(date1: Date, date2: Date): boolean { ); } -export const ChatMessages: React.FC = ({ +const ChatMessagesComponent: React.FC = ({ messages, userId, match, @@ -56,7 +56,14 @@ export const ChatMessages: React.FC = ({ }) => { const flatListRef = useRef(null); - const renderMessage = ({ item, index }: { item: Message; index: number }) => { + // Memoize keyExtractor to prevent recreation + const keyExtractor = useCallback((item: Message) => item.id, []); + + // Memoize user object for MatchCard to prevent re-renders + const userObject = useMemo(() => ({ id: userId || '' }), [userId]); + + // Memoize renderMessage to prevent recreation on every render + const renderMessage = useCallback(({ item, index }: { item: Message; index: number }) => { const isMe = item.user_id === userId; const messageDate = new Date(item.created_at!); @@ -70,7 +77,7 @@ export const ChatMessages: React.FC = ({ ); return ( - <> + = ({ /> {showDateSeparator && } - + ); - }; + }, [messages, userId, onMessageLongPress, revealMessage, onImagePress, onVideoFullScreen]); + + // Memoize ListHeaderComponent to prevent recreation + const listHeaderComponent = useMemo(() => ( + + {uploadStatus && } + {partnerTyping && } + + ), [uploadStatus, partnerTyping]); + + // Memoize ListFooterComponent to prevent recreation + const listFooterComponent = useMemo(() => ( + + + + ), [match, userObject]); + + // Memoize ListEmptyComponent to prevent recreation + const listEmptyComponent = useMemo(() => ( + + + + + + Start the Conversation + + Share your thoughts about this match + + + ), []); return ( item.id} + keyExtractor={keyExtractor} inverted style={styles.messageList} contentContainerStyle={styles.listContent} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} - ListHeaderComponent={ - <> - {/* Show uploading status when media is being uploaded */} - {uploadStatus && } - - {/* Show typing indicator when partner is typing */} - {partnerTyping && } - - } - ListFooterComponent={ - - - - } - ListEmptyComponent={ - - {/* Premium icon */} - - - - - {/* Decorative separator */} - - - Start the Conversation - - Share your thoughts about this match - - - } + // Performance optimizations + removeClippedSubviews={true} + maxToRenderPerBatch={15} + windowSize={10} + initialNumToRender={20} + ListHeaderComponent={listHeaderComponent} + ListFooterComponent={listFooterComponent} + ListEmptyComponent={listEmptyComponent} /> ); }; +// Wrap with React.memo for performance +export const ChatMessages = React.memo(ChatMessagesComponent); + const styles = StyleSheet.create({ messageList: { flex: 1, @@ -146,6 +163,9 @@ const styles = StyleSheet.create({ flexGrow: 1, padding: spacing.md, }, + footerContainer: { + paddingBottom: spacing.md, + }, emptyChat: { alignItems: 'center', justifyContent: 'center', diff --git a/apps/mobile/src/features/chat/components/ChatSettingsSheet.tsx b/apps/mobile/src/features/chat/components/ChatSettingsSheet.tsx new file mode 100644 index 0000000..79c6cb3 --- /dev/null +++ b/apps/mobile/src/features/chat/components/ChatSettingsSheet.tsx @@ -0,0 +1,247 @@ +import { useState } from "react"; +import { + View, + Text, + StyleSheet, + Modal, + TouchableOpacity, + TouchableWithoutFeedback, + ActivityIndicator, + Platform, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { BlurView } from "expo-blur"; +import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated"; +import { colors, spacing, typography, radius } from "../../../theme"; + +interface ChatSettingsSheetProps { + visible: boolean; + onClose: () => void; + isArchived: boolean; + onArchive: () => Promise; + onUnarchive: () => Promise; +} + +const ACCENT = colors.premium.gold; + +export function ChatSettingsSheet({ + visible, + onClose, + isArchived, + onArchive, + onUnarchive, +}: ChatSettingsSheetProps) { + const [isLoading, setIsLoading] = useState(false); + + const handleArchiveToggle = async () => { + if (isLoading) return; + + setIsLoading(true); + try { + if (isArchived) { + await onUnarchive(); + } else { + await onArchive(); + } + onClose(); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + + + + {Platform.OS === "ios" ? ( + + + + ) : ( + + + + )} + + + + ); +} + +function SheetContent({ + isArchived, + isLoading, + onClose, + onArchiveToggle, +}: { + isArchived: boolean; + isLoading: boolean; + onClose: () => void; + onArchiveToggle: () => void; +}) { + return ( + <> + {/* Handle */} + + + {/* Header */} + + Chat Settings + + + + + + {/* Archive option */} + + + + + + + + {isArchived ? "Unarchive Chat" : "Archive Chat"} + + + {isArchived + ? "Move this chat back to your main list" + : "Hide this chat from your main list"} + + + {isLoading && ( + + )} + + + + {/* Bottom padding for safe area */} + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: "flex-end", + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.6)", + }, + sheetContainer: { + maxHeight: "80%", + }, + sheet: { + borderTopLeftRadius: radius.xl, + borderTopRightRadius: radius.xl, + overflow: "hidden", + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + }, + sheetAndroid: { + backgroundColor: colors.backgroundLight, + }, + handle: { + width: 40, + height: 4, + backgroundColor: "rgba(255, 255, 255, 0.3)", + borderRadius: 2, + alignSelf: "center", + marginBottom: spacing.md, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + marginBottom: spacing.lg, + }, + headerTitle: { + ...typography.title3, + color: colors.text, + }, + closeButton: { + position: "absolute", + right: 0, + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: "rgba(255, 255, 255, 0.1)", + justifyContent: "center", + alignItems: "center", + }, + optionButton: { + paddingVertical: spacing.md, + paddingHorizontal: spacing.md, + borderRadius: radius.lg, + backgroundColor: "rgba(255, 255, 255, 0.05)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.1)", + marginBottom: spacing.md, + }, + optionContent: { + flexDirection: "row", + alignItems: "center", + }, + optionIconContainer: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: `rgba(212, 175, 55, 0.1)`, + justifyContent: "center", + alignItems: "center", + marginRight: spacing.md, + }, + optionTextContainer: { + flex: 1, + }, + optionTitle: { + ...typography.callout, + color: colors.text, + fontWeight: "600", + marginBottom: 2, + }, + optionDescription: { + ...typography.caption1, + color: colors.textSecondary, + }, + optionLoader: { + marginLeft: spacing.sm, + }, + bottomPadding: { + height: Platform.OS === "ios" ? 34 : 20, + }, +}); diff --git a/apps/mobile/src/features/chat/components/ChatVideoPlayer.tsx b/apps/mobile/src/features/chat/components/ChatVideoPlayer.tsx index 3a6d456..e017569 100644 --- a/apps/mobile/src/features/chat/components/ChatVideoPlayer.tsx +++ b/apps/mobile/src/features/chat/components/ChatVideoPlayer.tsx @@ -24,12 +24,12 @@ const ACCENT = colors.premium.gold; * Video player component with native-like tap behavior. * Supports play/pause, fullscreen, caching, and loading states. */ -export function ChatVideoPlayer({ +const ChatVideoPlayerComponent: React.FC = ({ signedUrl, storagePath, urlError, onFullScreen, -}: ChatVideoPlayerProps) { +}) => { const videoRef = useRef ); -} +}; + +// Wrap with React.memo for performance +export const InputBar = React.memo(InputBarComponent); const styles = StyleSheet.create({ container: { @@ -261,5 +264,3 @@ const styles = StyleSheet.create({ height: 20, }, }); - -export default InputBar; diff --git a/apps/mobile/src/features/chat/components/MatchCard.tsx b/apps/mobile/src/features/chat/components/MatchCard.tsx index 9191817..25a588b 100644 --- a/apps/mobile/src/features/chat/components/MatchCard.tsx +++ b/apps/mobile/src/features/chat/components/MatchCard.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { View, Text, StyleSheet } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; @@ -6,24 +6,38 @@ import Animated, { FadeInDown } from 'react-native-reanimated'; import { DecorativeSeparator } from '../../../components/ui'; import { colors, gradients, spacing, radius, typography } from '../../../theme'; +import { Match } from '../types'; // Premium color palette const ACCENT = colors.premium.gold; const ACCENT_RGBA = 'rgba(212, 175, 55, '; interface MatchCardProps { - match: any; + match: Match | null; user: { id: string } | null; } -export const MatchCard: React.FC = ({ match, user }) => { +const MatchCardComponent: React.FC = ({ match, user }) => { + // Track if animation has already played to prevent re-animation on re-renders + const hasAnimatedRef = useRef(false); + const shouldAnimate = !hasAnimatedRef.current; + + // Mark as animated after first render + if (!hasAnimatedRef.current) { + hasAnimatedRef.current = true; + } if (!match?.question) return null; const isYesYes = match?.match_type === 'yes_yes'; + // Only animate on first render to prevent re-animation when list re-renders + const enteringAnimation = shouldAnimate + ? FadeInDown.delay(100).duration(400).springify() + : undefined; + return ( @@ -66,19 +80,19 @@ export const MatchCard: React.FC = ({ match, user }) => { {(() => { // Sort responses by created_at to determine who answered first const sortedResponses = [...match.responses].sort( - (a: any, b: any) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() ); - const isTwoPart = !!match.question.partner_text; + const isTwoPart = !!match.question?.partner_text; - return sortedResponses.map((response: any, index: number) => { + return sortedResponses.map((response, index) => { const isUser = response.user_id === user?.id; const isYes = response.answer === 'yes'; const name = isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner'); // First responder sees main text, second sees partner_text const questionText = isTwoPart && index === 1 - ? match.question.partner_text - : match.question.text; + ? match.question?.partner_text + : match.question?.text; return ( @@ -130,6 +144,9 @@ export const MatchCard: React.FC = ({ match, user }) => { ); }; +// Wrap with React.memo for performance +export const MatchCard = React.memo(MatchCardComponent); + const styles = StyleSheet.create({ matchCardContainer: { marginHorizontal: spacing.md, diff --git a/apps/mobile/src/features/chat/components/MessageBubble.tsx b/apps/mobile/src/features/chat/components/MessageBubble.tsx index cdd7562..b2f643d 100644 --- a/apps/mobile/src/features/chat/components/MessageBubble.tsx +++ b/apps/mobile/src/features/chat/components/MessageBubble.tsx @@ -10,6 +10,9 @@ import { colors, gradients, spacing, radius } from '../../../theme'; const ACCENT_RGBA = 'rgba(212, 175, 55, '; +// Only animate the first few messages to avoid performance issues on large lists +const MAX_ANIMATED_INDEX = 5; + export interface MessageBubbleProps { isMe: boolean; index: number; @@ -18,10 +21,18 @@ export interface MessageBubbleProps { onLongPress?: () => void; } -export function MessageBubble({ isMe, index, children, onLongPress }: MessageBubbleProps) { +const MessageBubbleComponent = ({ isMe, index, children, onLongPress }: MessageBubbleProps) => { + // Only animate recent messages (low index in inverted list) to avoid performance issues + const shouldAnimate = index < MAX_ANIMATED_INDEX; + const enteringAnimation = shouldAnimate ? FadeInUp.duration(200) : undefined; + + const accessibilityLabel = isMe + ? 'Your message. Long press for options' + : "Partner's message. Long press for options"; + return ( {isMe ? ( @@ -30,6 +41,9 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub activeOpacity={0.9} delayLongPress={300} style={styles.myBubbleContainer} + accessibilityRole="button" + accessibilityLabel={accessibilityLabel} + accessibilityHint="Long press to delete or report" > {/* Subtle gradient background */} @@ -75,7 +92,10 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub )} ); -} +}; + +// Wrap with React.memo for performance +export const MessageBubble = React.memo(MessageBubbleComponent); const styles = StyleSheet.create({ messageRow: { @@ -116,5 +136,3 @@ const styles = StyleSheet.create({ height: 30, }, }); - -export default MessageBubble; diff --git a/apps/mobile/src/features/chat/components/MessageContent.tsx b/apps/mobile/src/features/chat/components/MessageContent.tsx index ce8013b..329642a 100644 --- a/apps/mobile/src/features/chat/components/MessageContent.tsx +++ b/apps/mobile/src/features/chat/components/MessageContent.tsx @@ -22,18 +22,16 @@ export interface MessageContentProps { revealMessage: (id: string) => void; onImagePress: (uri: string) => void; onVideoFullScreen: (uri: string) => void; - onLongPress?: () => void; } -export function MessageContent({ +const MessageContentComponent: React.FC = ({ item, isMe, currentUserId, revealMessage, onImagePress, onVideoFullScreen, - onLongPress, -}: MessageContentProps) { +}) => { // Check if message was deleted for everyone if (item.deleted_at) { return ( @@ -50,7 +48,7 @@ export function MessageContent({ ); } - const isVideo = (item as any).media_type === 'video'; + const isVideo = item.media_type === 'video'; const isViewed = !!item.media_viewed_at; const isRecipientHidden = !isMe && !isViewed; @@ -87,7 +85,7 @@ export function MessageContent({ }, [item.media_path, isRecipientHidden]); // Handle expired videos - const isExpired = !!(item as any).media_expired; + const isExpired = !!item.media_expired; if (isExpired && isVideo) { return ( @@ -240,7 +238,10 @@ export function MessageContent({ ); -} +}; + +// Wrap with React.memo for performance +export const MessageContent = React.memo(MessageContentComponent); const styles = StyleSheet.create({ deletedContainer: { @@ -347,5 +348,3 @@ const styles = StyleSheet.create({ paddingHorizontal: spacing.md, }, }); - -export default MessageContent; diff --git a/apps/mobile/src/features/chat/components/MessageMeta.tsx b/apps/mobile/src/features/chat/components/MessageMeta.tsx index 6d074ed..10aa4e7 100644 --- a/apps/mobile/src/features/chat/components/MessageMeta.tsx +++ b/apps/mobile/src/features/chat/components/MessageMeta.tsx @@ -12,7 +12,7 @@ export interface MessageMetaProps { /** * Message metadata component showing timestamp and read receipts. */ -export function MessageMeta({ item, isMe }: MessageMetaProps) { +const MessageMetaComponent: React.FC = ({ item, isMe }) => { const status = getReceiptStatus(item.delivered_at, item.read_at); return ( @@ -27,7 +27,10 @@ export function MessageMeta({ item, isMe }: MessageMetaProps) { )} ); -} +}; + +// Wrap with React.memo for performance +export const MessageMeta = React.memo(MessageMetaComponent); const styles = StyleSheet.create({ container: { @@ -43,5 +46,3 @@ const styles = StyleSheet.create({ marginLeft: spacing.xs, }, }); - -export default MessageMeta; diff --git a/apps/mobile/src/features/chat/components/TypingIndicator.tsx b/apps/mobile/src/features/chat/components/TypingIndicator.tsx index 0dcd2db..3759d3f 100644 --- a/apps/mobile/src/features/chat/components/TypingIndicator.tsx +++ b/apps/mobile/src/features/chat/components/TypingIndicator.tsx @@ -15,7 +15,7 @@ interface TypingDotProps { delay: number; } -function TypingDot({ delay }: TypingDotProps) { +const TypingDot = React.memo(function TypingDot({ delay }: TypingDotProps) { const opacity = useSharedValue(0.4); useEffect(() => { @@ -43,13 +43,13 @@ function TypingDot({ delay }: TypingDotProps) { /> ); -} +}); /** * Typing indicator component showing three animated dots * when partner is typing. */ -export function TypingIndicator() { +const TypingIndicatorComponent: React.FC = () => { return ( ); -} +}; + +// Wrap with React.memo for performance +export const TypingIndicator = React.memo(TypingIndicatorComponent); const styles = StyleSheet.create({ container: { @@ -104,5 +107,3 @@ const styles = StyleSheet.create({ borderRadius: 4, }, }); - -export default TypingIndicator; diff --git a/apps/mobile/src/features/chat/components/index.ts b/apps/mobile/src/features/chat/components/index.ts index d603453..1703325 100644 --- a/apps/mobile/src/features/chat/components/index.ts +++ b/apps/mobile/src/features/chat/components/index.ts @@ -16,3 +16,6 @@ export { ChatHeader } from './ChatHeader'; export { ChatMessages } from './ChatMessages'; export { MatchCard } from './MatchCard'; export { ReportMessageSheet } from './ReportMessageSheet'; +export { ChatSettingsSheet } from './ChatSettingsSheet'; +export { FullScreenImageModal } from './FullScreenImageModal'; +export { FullScreenVideoModal } from './FullScreenVideoModal'; diff --git a/apps/mobile/src/features/chat/types.ts b/apps/mobile/src/features/chat/types.ts index 56b275e..6e46b72 100644 --- a/apps/mobile/src/features/chat/types.ts +++ b/apps/mobile/src/features/chat/types.ts @@ -25,6 +25,7 @@ export interface Match { responses?: Array<{ user_id: string; answer: string; + created_at: string; profiles?: { name?: string; }; diff --git a/apps/mobile/src/hooks/useTypingIndicator.ts b/apps/mobile/src/hooks/useTypingIndicator.ts index 1a12229..e12729c 100644 --- a/apps/mobile/src/hooks/useTypingIndicator.ts +++ b/apps/mobile/src/hooks/useTypingIndicator.ts @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useCallback } from 'react'; +import { RealtimeChannel } from '@supabase/supabase-js'; import { supabase } from '../lib/supabase'; export interface UseTypingIndicatorConfig { @@ -10,6 +11,8 @@ export interface UseTypingIndicatorConfig { typingTimeout?: number; /** Whether the screen is currently focused */ isFocused?: boolean; + /** Throttle interval in ms for sending typing events. Default: 2000 */ + throttleInterval?: number; } export interface UseTypingIndicatorReturn { @@ -22,6 +25,7 @@ export interface UseTypingIndicatorReturn { } const DEFAULT_TYPING_TIMEOUT = 3000; +const DEFAULT_THROTTLE_INTERVAL = 2000; /** * Hook for managing typing indicator state and broadcast events. @@ -33,11 +37,19 @@ const DEFAULT_TYPING_TIMEOUT = 3000; export const useTypingIndicator = ( config: UseTypingIndicatorConfig ): UseTypingIndicatorReturn => { - const { channelName, userId, typingTimeout = DEFAULT_TYPING_TIMEOUT, isFocused = true } = config; + const { + channelName, + userId, + typingTimeout = DEFAULT_TYPING_TIMEOUT, + isFocused = true, + throttleInterval = DEFAULT_THROTTLE_INTERVAL, + } = config; const [partnerTyping, setPartnerTyping] = useState(false); const typingTimeoutRef = useRef(null); const isFocusedRef = useRef(isFocused); + const lastTypingEventRef = useRef(0); + const channelRef = useRef(null); // Update ref when focus changes and clear state if blurred useEffect(() => { @@ -65,6 +77,7 @@ export const useTypingIndicator = ( if (!channelName || !userId) return; const channel = supabase.channel(channelName); + channelRef.current = channel; channel.on('broadcast', { event: 'typing' }, (payload) => { // Only process events if the screen is focused @@ -88,6 +101,7 @@ export const useTypingIndicator = ( channel.subscribe(); return () => { + channelRef.current = null; supabase.removeChannel(channel); if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); @@ -96,17 +110,22 @@ export const useTypingIndicator = ( }, [channelName, userId, typingTimeout]); /** - * Send a typing event to the partner + * Send a typing event to the partner (throttled to reduce network traffic) */ const sendTypingEvent = useCallback(() => { - if (!channelName || !userId) return; + if (!channelRef.current || !userId) return; - supabase.channel(channelName).send({ + // Throttle typing events to reduce network traffic + const now = Date.now(); + if (now - lastTypingEventRef.current < throttleInterval) return; + lastTypingEventRef.current = now; + + channelRef.current.send({ type: 'broadcast', event: 'typing', payload: { userId }, }); - }, [channelName, userId]); + }, [userId, throttleInterval]); /** * Manually clear the typing indicator (e.g., when a message is received) diff --git a/apps/mobile/src/store/matchStore.ts b/apps/mobile/src/store/matchStore.ts index f948ead..fc7605e 100644 --- a/apps/mobile/src/store/matchStore.ts +++ b/apps/mobile/src/store/matchStore.ts @@ -14,12 +14,24 @@ interface MatchState { page: number; hasMore: boolean; error: string | null; + // Archive state + archivedMatches: Match[]; + archivedMatchIds: Set; + showArchived: boolean; + isLoadingArchived: boolean; + // Methods fetchMatches: (refresh?: boolean) => Promise; markAsSeen: (matchId: string) => Promise; markAllAsSeen: () => Promise; addMatch: (match: Match) => void; updateMatchUnreadCount: (matchId: string, delta: number) => void; clearMatches: () => void; + // Archive methods + archiveMatch: (matchId: string) => Promise; + unarchiveMatch: (matchId: string) => Promise; + fetchArchivedMatches: () => Promise; + toggleShowArchived: () => void; + isMatchArchived: (matchId: string) => boolean; } export const useMatchStore = create((set, get) => ({ @@ -31,6 +43,11 @@ export const useMatchStore = create((set, get) => ({ page: 0, hasMore: true, error: null, + // Archive state + archivedMatches: [], + archivedMatchIds: new Set(), + showArchived: false, + isLoadingArchived: false, fetchMatches: async (refresh = false) => { const userId = useAuthStore.getState().user?.id; @@ -57,7 +74,18 @@ export const useMatchStore = create((set, get) => ({ const from = currentPage * BATCH_SIZE; const to = from + BATCH_SIZE - 1; - // Fetch total count on refresh + // Fetch archived match IDs for this user on refresh + let archivedMatchIds = state.archivedMatchIds; + if (refresh && userId) { + const { data: archives } = await supabase + .from("match_archives") + .select("match_id") + .eq("user_id", userId); + + archivedMatchIds = new Set(archives?.map(a => a.match_id) ?? []); + } + + // Fetch total count on refresh (excluding archived) let totalCount = state.totalCount; if (refresh) { const { count, error: countError } = await supabase @@ -66,7 +94,8 @@ export const useMatchStore = create((set, get) => ({ .eq("couple_id", coupleId); if (!countError) { - totalCount = count ?? 0; + // Subtract archived count from total + totalCount = (count ?? 0) - archivedMatchIds.size; } } @@ -81,9 +110,13 @@ export const useMatchStore = create((set, get) => ({ .range(from, to); if (matchError) throw matchError; - if (!matches || matches.length === 0) { + + // Filter out archived matches + const nonArchivedMatches = matches?.filter(m => !archivedMatchIds.has(m.id)) ?? []; + + if (nonArchivedMatches.length === 0) { if (refresh) { - set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false }); + set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false, archivedMatchIds }); } else { set({ isLoadingMore: false, hasMore: false }); } @@ -91,7 +124,7 @@ export const useMatchStore = create((set, get) => ({ } // Fetch all responses for these questions to determine who answered first - const questionIds = matches.map(m => m.question_id); + const questionIds = nonArchivedMatches.map(m => m.question_id); let responses: any[] = []; if (questionIds.length > 0) { const { data } = await supabase @@ -102,7 +135,7 @@ export const useMatchStore = create((set, get) => ({ } // Fetch unread message counts per match - const matchIds = matches.map(m => m.id); + const matchIds = nonArchivedMatches.map(m => m.id); let unreadCounts: Record = {}; if (userId && matchIds.length > 0) { @@ -123,7 +156,7 @@ export const useMatchStore = create((set, get) => ({ }); } - const data = matches.map(match => ({ + const data = nonArchivedMatches.map(match => ({ ...match, responses: responses.filter(r => r.question_id === match.question_id), unreadCount: unreadCounts[match.id] || 0 @@ -165,7 +198,8 @@ export const useMatchStore = create((set, get) => ({ isLoading: false, isLoadingMore: false, page: currentPage + 1, - hasMore: matches.length === BATCH_SIZE + hasMore: nonArchivedMatches.length === BATCH_SIZE, + archivedMatchIds: refresh ? archivedMatchIds : state.archivedMatchIds, }; }); } catch (err) { @@ -222,6 +256,195 @@ export const useMatchStore = create((set, get) => ({ }, clearMatches: () => { - set({ matches: [], newMatchesCount: 0, totalCount: null, isLoading: false, error: null, page: 0, hasMore: true, isLoadingMore: false }); + set({ + matches: [], + newMatchesCount: 0, + totalCount: null, + isLoading: false, + error: null, + page: 0, + hasMore: true, + isLoadingMore: false, + archivedMatches: [], + archivedMatchIds: new Set(), + showArchived: false, + isLoadingArchived: false, + }); + }, + + // Archive methods + archiveMatch: async (matchId: string) => { + const userId = useAuthStore.getState().user?.id; + if (!userId) return; + + // Optimistically update UI + const state = get(); + const matchToArchive = state.matches.find(m => m.id === matchId); + + if (matchToArchive) { + const newArchivedMatchIds = new Set(state.archivedMatchIds); + newArchivedMatchIds.add(matchId); + + set({ + matches: state.matches.filter(m => m.id !== matchId), + archivedMatches: [...state.archivedMatches, matchToArchive], + archivedMatchIds: newArchivedMatchIds, + totalCount: state.totalCount !== null ? state.totalCount - 1 : null, + }); + } + + // Insert archive record + const { error } = await supabase + .from("match_archives") + .insert({ match_id: matchId, user_id: userId }); + + if (error) { + console.error("Error archiving match:", error); + // Revert on error + if (matchToArchive) { + const revertArchivedIds = new Set(get().archivedMatchIds); + revertArchivedIds.delete(matchId); + const currentTotalCount = get().totalCount; + set({ + matches: [matchToArchive, ...get().matches], + archivedMatches: get().archivedMatches.filter(m => m.id !== matchId), + archivedMatchIds: revertArchivedIds, + totalCount: currentTotalCount !== null ? currentTotalCount + 1 : null, + }); + } + } + }, + + unarchiveMatch: async (matchId: string) => { + const userId = useAuthStore.getState().user?.id; + if (!userId) return; + + // Optimistically update UI + const state = get(); + const matchToUnarchive = state.archivedMatches.find(m => m.id === matchId); + + if (matchToUnarchive) { + const newArchivedMatchIds = new Set(state.archivedMatchIds); + newArchivedMatchIds.delete(matchId); + + set({ + archivedMatches: state.archivedMatches.filter(m => m.id !== matchId), + matches: [matchToUnarchive, ...state.matches], + archivedMatchIds: newArchivedMatchIds, + totalCount: state.totalCount !== null ? state.totalCount + 1 : null, + }); + } + + // Delete archive record + const { error } = await supabase + .from("match_archives") + .delete() + .eq("match_id", matchId) + .eq("user_id", userId); + + if (error) { + console.error("Error unarchiving match:", error); + // Revert on error + if (matchToUnarchive) { + const revertArchivedIds = new Set(get().archivedMatchIds); + revertArchivedIds.add(matchId); + const currentTotalCount = get().totalCount; + set({ + archivedMatches: [matchToUnarchive, ...get().archivedMatches], + matches: get().matches.filter(m => m.id !== matchId), + archivedMatchIds: revertArchivedIds, + totalCount: currentTotalCount !== null ? currentTotalCount - 1 : null, + }); + } + } + }, + + fetchArchivedMatches: async () => { + const userId = useAuthStore.getState().user?.id; + const coupleId = useAuthStore.getState().user?.couple_id; + + if (!coupleId || !userId) return; + + const state = get(); + if (state.isLoadingArchived) return; + + set({ isLoadingArchived: true }); + + try { + // Get archived match IDs for this user + const { data: archives, error: archiveError } = await supabase + .from("match_archives") + .select("match_id") + .eq("user_id", userId); + + if (archiveError) throw archiveError; + + const archivedIds = archives?.map(a => a.match_id) ?? []; + + if (archivedIds.length === 0) { + set({ archivedMatches: [], isLoadingArchived: false }); + return; + } + + // Fetch the actual match data + const { data: matches, error: matchError } = await supabase + .from("matches") + .select(` + *, + question:questions(*) + `) + .in("id", archivedIds) + .eq("couple_id", coupleId) + .order("created_at", { ascending: false }); + + if (matchError) throw matchError; + + // Fetch unread counts for archived matches + const matchIds = matches?.map(m => m.id) ?? []; + let unreadCounts: Record = {}; + + if (matchIds.length > 0) { + const { data: unreadMessages } = await supabase + .from("messages") + .select("match_id") + .in("match_id", matchIds) + .neq("user_id", userId) + .is("read_at", null); + + unreadMessages?.forEach(msg => { + unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1; + }); + } + + const archivedMatches = (matches ?? []).map(match => ({ + ...match, + unreadCount: unreadCounts[match.id] || 0 + })); + + set({ + archivedMatches, + isLoadingArchived: false, + archivedMatchIds: new Set(archivedIds), + }); + } catch (err) { + console.error("Error fetching archived matches:", err); + set({ isLoadingArchived: false }); + } + }, + + toggleShowArchived: () => { + const state = get(); + const newShowArchived = !state.showArchived; + + set({ showArchived: newShowArchived }); + + // Fetch archived matches if switching to archived view and not yet loaded + if (newShowArchived && state.archivedMatches.length === 0) { + get().fetchArchivedMatches(); + } + }, + + isMatchArchived: (matchId: string) => { + return get().archivedMatchIds.has(matchId); }, })); diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts index 5deb119..8cd7c49 100755 --- a/apps/mobile/src/types/index.ts +++ b/apps/mobile/src/types/index.ts @@ -1,8 +1,8 @@ // User profile (extends Supabase auth.users) -export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say'; -export type IntensityLevel = 1 | 2 | 3 | 4 | 5; - -export interface Profile { +export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say'; +export type IntensityLevel = 1 | 2 | 3 | 4 | 5; + +export interface Profile { id: string; name: string | null; email: string | null; @@ -10,10 +10,10 @@ export interface Profile { push_token: string | null; is_premium: boolean; couple_id: string | null; - gender: Gender | null; - show_explicit_content: boolean; - max_intensity: IntensityLevel; - onboarding_completed: boolean; + gender: Gender | null; + show_explicit_content: boolean; + max_intensity: IntensityLevel; + onboarding_completed: boolean; created_at: string; updated_at: string; /** RSA public key for E2EE (JWK format) */ @@ -44,12 +44,12 @@ export interface QuestionPack { description: string | null; icon: string | null; is_premium: boolean; - is_public: boolean; - is_explicit: boolean; - min_intensity?: number; - max_intensity?: number; - avg_intensity?: number; - sort_order: number; + is_public: boolean; + is_explicit: boolean; + min_intensity?: number; + max_intensity?: number; + avg_intensity?: number; + sort_order: number; category_id: string | null; category?: Category; questions?: { count: number }[]; @@ -100,6 +100,14 @@ export interface MatchWithQuestion extends Match { question: Question; } +// Match archive (per-user) +export interface MatchArchive { + id: string; + match_id: string; + user_id: string; + archived_at: string; +} + // Pack with progress info export interface PackProgress { pack: QuestionPack; diff --git a/apps/mobile/src/types/supabase.ts b/apps/mobile/src/types/supabase.ts index 94d7411..f3a2431 100755 --- a/apps/mobile/src/types/supabase.ts +++ b/apps/mobile/src/types/supabase.ts @@ -98,18 +98,21 @@ export type Database = { app_config: { Row: { answer_gap_threshold: number | null + daily_response_limit: number | null id: string updated_at: string | null updated_by: string | null } Insert: { answer_gap_threshold?: number | null + daily_response_limit?: number | null id?: string updated_at?: string | null updated_by?: string | null } Update: { answer_gap_threshold?: number | null + daily_response_limit?: number | null id?: string updated_at?: string | null updated_by?: string | null @@ -373,6 +376,42 @@ export type Database = { } Relationships: [] } + match_archives: { + Row: { + id: string + match_id: string + user_id: string + archived_at: string | null + } + Insert: { + id?: string + match_id: string + user_id: string + archived_at?: string | null + } + Update: { + id?: string + match_id?: string + user_id?: string + archived_at?: string | null + } + Relationships: [ + { + foreignKeyName: "match_archives_match_id_fkey" + columns: ["match_id"] + isOneToOne: false + referencedRelation: "matches" + referencedColumns: ["id"] + }, + { + foreignKeyName: "match_archives_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } matches: { Row: { couple_id: string @@ -925,6 +964,16 @@ export type Database = { }[] } get_auth_user_couple_id: { Args: never; Returns: string } + get_daily_response_limit_status: { + Args: never + Returns: { + is_blocked: boolean + limit_value: number + remaining: number + reset_at: string + responses_today: number + }[] + } get_feature_interest_counts: { Args: never Returns: { diff --git a/apps/supabase/functions/classify-message/index.ts b/apps/supabase/functions/classify-message/index.ts index b016e21..36862c6 100644 --- a/apps/supabase/functions/classify-message/index.ts +++ b/apps/supabase/functions/classify-message/index.ts @@ -9,6 +9,174 @@ const corsHeaders = { "Access-Control-Allow-Methods": "POST, OPTIONS", }; +const DEFAULT_WHITELIST = [ + "ok", + "okay", + "yes", + "no", + "maybe", + "thanks", + "thank you", + "lol", + "lmao", + "good night", + "goodnight", + "good morning", + "love you", +]; + +const DEFAULT_KEYWORD_TRIGGERS = [ + "suicide", + "kill myself", + "self harm", + "kill you", + "hurt you", + "rape", + "porn", + "nude", + "explicit", + "stab", + "shoot", +]; + +function parseBoolean(value, defaultValue = false) { + if (value === null || value === undefined) return defaultValue; + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["true", "1", "yes", "y", "on"].includes(normalized)) return true; + if (["false", "0", "no", "n", "off"].includes(normalized)) return false; + } + return defaultValue; +} + +function parseNumber(value, defaultValue) { + if (value === null || value === undefined) return defaultValue; + if (typeof value === "number" && Number.isFinite(value)) return value; + const parsed = Number(String(value).trim()); + return Number.isFinite(parsed) ? parsed : defaultValue; +} + +function parseStringArray(value) { + if (!value) return []; + if (Array.isArray(value)) { + return value + .filter((item) => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean); + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return []; + if (trimmed.startsWith("[") || trimmed.startsWith("{")) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed + .filter((item) => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean); + } + } catch { + // Fall through to delimiter parsing + } + } + return trimmed + .split(/[\n,]+/) + .map((item) => item.trim()) + .filter(Boolean); + } + return []; +} + +function normalizeList(values) { + return Array.from( + new Set( + values + .map((value) => value.trim().toLowerCase()) + .filter(Boolean) + ) + ); +} + +function getHeuristicConfig(config) { + const enabled = parseBoolean(config?.heuristics_enabled, false); + const minTextLength = parseNumber(config?.heuristic_min_text_length, 12); + const whitelistMaxLength = parseNumber(config?.heuristic_whitelist_max_length, 30); + const skipIfNoAlnum = parseBoolean(config?.heuristic_skip_if_no_alnum, true); + const skipMediaWithoutText = parseBoolean(config?.heuristic_skip_media_without_text, false); + const recordReason = parseBoolean(config?.heuristic_record_reason, false); + const useDefaultWhitelist = parseBoolean(config?.heuristic_use_default_whitelist, true); + const useDefaultKeywords = parseBoolean(config?.heuristic_use_default_keywords, true); + + const whitelist = parseStringArray(config?.heuristic_whitelist); + const keywordTriggers = parseStringArray(config?.heuristic_keyword_triggers); + + const normalizedWhitelist = normalizeList( + useDefaultWhitelist ? DEFAULT_WHITELIST.concat(whitelist) : whitelist + ); + const normalizedKeywordTriggers = normalizeList( + useDefaultKeywords ? DEFAULT_KEYWORD_TRIGGERS.concat(keywordTriggers) : keywordTriggers + ); + + return { + enabled, + minTextLength, + whitelistMaxLength, + skipIfNoAlnum, + skipMediaWithoutText, + recordReason, + whitelist: normalizedWhitelist, + keywordTriggers: normalizedKeywordTriggers, + }; +} + +function evaluateHeuristics(text, hasMedia, config) { + if (!config.enabled) { + return { shouldCallAi: true, reason: "disabled" }; + } + + const normalized = (text || "").trim(); + const lower = normalized.toLowerCase(); + const hasText = normalized.length > 0; + const hasAlnum = /[a-z0-9]/i.test(normalized); + + if (config.keywordTriggers.length > 0) { + const keywordHit = config.keywordTriggers.some((keyword) => lower.includes(keyword)); + if (keywordHit) { + return { shouldCallAi: true, reason: "keyword_trigger" }; + } + } + + if (!hasMedia && !hasText) { + return { shouldCallAi: false, reason: "no_content" }; + } + + if (!hasMedia) { + if (config.skipIfNoAlnum && !hasAlnum) { + return { shouldCallAi: false, reason: "low_signal" }; + } + + if (normalized.length <= config.whitelistMaxLength) { + const isWhitelisted = config.whitelist.includes(lower); + if (isWhitelisted) { + return { shouldCallAi: false, reason: "whitelist" }; + } + } + + if (normalized.length < config.minTextLength) { + return { shouldCallAi: false, reason: "short_text" }; + } + } + + if (hasMedia && config.skipMediaWithoutText && !hasText) { + return { shouldCallAi: false, reason: "media_without_text" }; + } + + return { shouldCallAi: true, reason: "default" }; +} + Deno.serve(async (req) => { if (req.method === "OPTIONS") { return new Response("ok", { headers: corsHeaders }); @@ -42,6 +210,8 @@ Deno.serve(async (req) => { return new Response(JSON.stringify({ message: "Classifier disabled" }), { status: 200, headers: corsHeaders }); } + const heuristicConfig = getHeuristicConfig(config); + const apiKey = config.openrouter_api_key || Deno.env.get("OPENROUTER_API_KEY"); if (!apiKey) { console.error("OpenRouter API Key missing"); @@ -64,24 +234,31 @@ Deno.serve(async (req) => { let decryptedText = message.content; let mediaBase64: string | null = null; let mediaMimeType: string | null = null; + const hasMedia = !!message.media_path && message.media_type === "image"; + const keysMetadata = message.keys_metadata as KeysMetadata | null; + const encryptionIv = message.encryption_iv as string | null; + let aesKey: CryptoKey | null = null; - if (version === 2) { - const keysMetadata = message.keys_metadata as KeysMetadata; - const encryptionIv = message.encryption_iv; - + if (version === 2 && (message.encrypted_content || hasMedia)) { if (!keysMetadata || !encryptionIv) { return new Response(JSON.stringify({ error: "Missing encryption metadata" }), { status: 400, headers: corsHeaders }); } + } + const getAesKey = async () => { + if (aesKey) return aesKey; + if (!keysMetadata || !encryptionIv) { + throw new Error("Missing encryption metadata"); + } const adminPrivateKeyJwkStr = Deno.env.get("ADMIN_PRIVATE_KEY_JWK"); if (!adminPrivateKeyJwkStr) { throw new Error("ADMIN_PRIVATE_KEY_JWK not set"); } - + const adminPrivateKey = JSON.parse(adminPrivateKeyJwkStr); const rawAesKey = await unwrapWithAdminKey(keysMetadata.admin_wrapped_key, adminPrivateKey); - - const aesKey = await crypto.subtle.importKey( + + aesKey = await crypto.subtle.importKey( "raw", rawAesKey, { name: "AES-GCM" }, @@ -89,45 +266,60 @@ Deno.serve(async (req) => { ["decrypt"] ); - // Decrypt Text - if (message.encrypted_content) { - const plaintext = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) }, - aesKey, - base64ToUint8Array(message.encrypted_content) - ); - decryptedText = new TextDecoder().decode(plaintext); - } - - // Decrypt Media (Image only for now) - if (message.media_path && message.media_type === "image") { + return aesKey; + }; + + if (version === 2 && message.encrypted_content) { + const key = await getAesKey(); + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: base64ToUint8Array(encryptionIv as string) }, + key, + base64ToUint8Array(message.encrypted_content) + ); + decryptedText = new TextDecoder().decode(plaintext); + } + + const heuristicDecision = evaluateHeuristics(decryptedText, hasMedia, heuristicConfig); + if (heuristicConfig.enabled && !heuristicDecision.shouldCallAi) { + console.log(`Heuristic skip for message ${messageId}: ${heuristicDecision.reason}`); + await updateStatus( + supabaseAdmin, + messageId, + "safe", + heuristicConfig.recordReason ? heuristicDecision.reason : null, + "Neutral" + ); + return new Response(JSON.stringify({ status: "safe" }), { headers: corsHeaders }); + } + + if (hasMedia) { + if (version === 2) { + const key = await getAesKey(); const { data: mediaBlob, error: downloadError } = await supabaseAdmin.storage - .from("chat-media") - .download(normalizeChatMediaPath(message.media_path)); + .from("chat-media") + .download(normalizeChatMediaPath(message.media_path)); if (!downloadError && mediaBlob) { - const encryptedBytes = await mediaBlob.arrayBuffer(); - const decryptedBytes = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) }, - aesKey, - encryptedBytes - ); - mediaBase64 = arrayBufferToBase64(decryptedBytes); - mediaMimeType = "image/jpeg"; // Default or infer? + const encryptedBytes = await mediaBlob.arrayBuffer(); + const decryptedBytes = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: base64ToUint8Array(encryptionIv as string) }, + key, + encryptedBytes + ); + mediaBase64 = arrayBufferToBase64(decryptedBytes); + mediaMimeType = "image/jpeg"; + } + } else { + const { data: mediaBlob } = await supabaseAdmin.storage + .from("chat-media") + .download(normalizeChatMediaPath(message.media_path)); + + if (mediaBlob) { + const buffer = await mediaBlob.arrayBuffer(); + mediaBase64 = arrayBufferToBase64(buffer); + mediaMimeType = "image/jpeg"; } } - } else { - // Version 1 (Plaintext) - if (message.media_path && message.media_type === "image") { - const { data: mediaBlob } = await supabaseAdmin.storage - .from("chat-media") - .download(normalizeChatMediaPath(message.media_path)); - if (mediaBlob) { - const buffer = await mediaBlob.arrayBuffer(); - mediaBase64 = arrayBufferToBase64(buffer); - mediaMimeType = "image/jpeg"; - } - } } // 4. Construct LLM Payload diff --git a/apps/supabase/functions/submit-response/index.ts b/apps/supabase/functions/submit-response/index.ts index dfaa8db..f899b1c 100755 --- a/apps/supabase/functions/submit-response/index.ts +++ b/apps/supabase/functions/submit-response/index.ts @@ -70,27 +70,88 @@ Deno.serve(async (req) => { .eq("id", user.id) .single(); - if (profileError || !profile?.couple_id) { - return new Response( - JSON.stringify({ error: "User is not in a couple" }), - { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); - } - - // Save or update response - const { data: response, error: responseError } = await supabase - .from("responses") - .upsert( - { - user_id: user.id, - question_id, - couple_id: profile.couple_id, - answer, - }, - { onConflict: "user_id,question_id" } - ) - .select() - .single(); + if (profileError || !profile?.couple_id) { + return new Response( + JSON.stringify({ error: "User is not in a couple" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // Check if this is a new response or an edit (edits bypass daily limit) + const { data: existingResponse } = await supabase + .from("responses") + .select("id") + .eq("user_id", user.id) + .eq("question_id", question_id) + .maybeSingle(); + + const isNewResponse = !existingResponse; + + // Enforce daily response limit for new responses only + if (isNewResponse) { + // Check if user has premium access + const { data: hasPremiumData } = await supabase.rpc("has_premium_access", { + check_user_id: user.id, + }); + + const hasPremium = hasPremiumData === true; + + // If non-premium, check daily limit + if (!hasPremium) { + // Get daily limit from app_config + const { data: configData } = await supabase + .from("app_config") + .select("daily_response_limit") + .limit(1) + .maybeSingle(); + + const dailyLimit = configData?.daily_response_limit || 0; + + // If daily limit is enabled (> 0), check if user has reached it + if (dailyLimit > 0) { + // Calculate UTC day boundaries + const now = new Date(); + const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const tomorrowStart = new Date(todayStart); + tomorrowStart.setUTCDate(tomorrowStart.getUTCDate() + 1); + + // Count responses created today (UTC) + const { count: dailyCount } = await supabase + .from("responses") + .select("*", { count: "exact", head: true }) + .eq("user_id", user.id) + .gte("created_at", todayStart.toISOString()) + .lt("created_at", tomorrowStart.toISOString()); + + // If over limit, reject + if ((dailyCount || 0) >= dailyLimit) { + return new Response( + JSON.stringify({ + error: "Daily response limit reached", + daily_limit: dailyLimit, + reset_at: tomorrowStart.toISOString(), + }), + { status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + } + } + } + + // Save or update response + const { data: response, error: responseError } = await supabase + .from("responses") + .upsert( + { + user_id: user.id, + question_id, + couple_id: profile.couple_id, + answer, + }, + { onConflict: "user_id,question_id" } + ) + .select() + .single(); if (responseError) { return new Response( diff --git a/apps/supabase/migrations/20260109000000_add_admin_username_to_audit_logs.sql b/apps/supabase/migrations/20260109000000_add_admin_username_to_audit_logs.sql new file mode 100644 index 0000000..f794b39 --- /dev/null +++ b/apps/supabase/migrations/20260109000000_add_admin_username_to_audit_logs.sql @@ -0,0 +1,66 @@ +-- Add admin_username column to audit_logs table +ALTER TABLE audit_logs +ADD COLUMN admin_username TEXT; + +-- Backfill existing audit logs with admin names from profiles +UPDATE audit_logs +SET admin_username = COALESCE(p.name, p.email, 'Unknown') +FROM profiles p +WHERE audit_logs.admin_user_id = p.id + AND audit_logs.admin_username IS NULL; + +-- Update the log_admin_action function to populate admin_username from profiles +CREATE OR REPLACE FUNCTION log_admin_action( + p_table_name TEXT, + p_record_id UUID, + p_action audit_action, + p_old_values JSONB DEFAULT NULL, + p_new_values JSONB DEFAULT NULL +) +RETURNS UUID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = 'public' +AS $$ +DECLARE + log_id UUID; + admin_role_val public.admin_role; + admin_username_val TEXT; + changed_fields_arr TEXT[]; +BEGIN + -- Get admin role from admin_users table + SELECT role + INTO admin_role_val + FROM public.admin_users + WHERE user_id = auth.uid(); + + IF admin_role_val IS NULL THEN + RAISE EXCEPTION 'User is not an admin'; + END IF; + + -- Get username from profiles table (name or email as fallback) + SELECT COALESCE(name, email, 'Unknown') + INTO admin_username_val + FROM public.profiles + WHERE id = auth.uid(); + + IF p_action = 'UPDATE' AND p_old_values IS NOT NULL AND p_new_values IS NOT NULL THEN + SELECT ARRAY( + SELECT key FROM jsonb_each(p_new_values) + WHERE p_old_values->key IS DISTINCT FROM p_new_values->key + ) INTO changed_fields_arr; + END IF; + + INSERT INTO public.audit_logs ( + table_name, record_id, action, old_values, new_values, + changed_fields, admin_user_id, admin_role, admin_username + ) + VALUES ( + p_table_name, p_record_id, p_action, p_old_values, p_new_values, + changed_fields_arr, auth.uid(), admin_role_val, admin_username_val + ) + RETURNING id INTO log_id; + + RETURN log_id; +END; +$$; diff --git a/apps/supabase/migrations/20260109100000_add_match_archives.sql b/apps/supabase/migrations/20260109100000_add_match_archives.sql new file mode 100644 index 0000000..3ee7ff9 --- /dev/null +++ b/apps/supabase/migrations/20260109100000_add_match_archives.sql @@ -0,0 +1,46 @@ +-- Create match_archives table for per-user match archiving +-- Similar pattern to message_deletions table +CREATE TABLE match_archives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + match_id UUID NOT NULL REFERENCES matches(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + archived_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(match_id, user_id) +); + +-- Index for efficient lookup when filtering matches +CREATE INDEX idx_match_archives_user_match ON match_archives(user_id, match_id); + +-- Enable RLS on match_archives +ALTER TABLE match_archives ENABLE ROW LEVEL SECURITY; + +-- RLS Policies for match_archives + +-- Users can view their own archives (needed for filtering) +CREATE POLICY "Users can view own archives" + ON match_archives FOR SELECT + USING (user_id = auth.uid()); + +-- Users can insert their own archives (for matches in their couples) +CREATE POLICY "Users can archive matches" + ON match_archives FOR INSERT + WITH CHECK ( + user_id = auth.uid() AND + EXISTS ( + SELECT 1 FROM matches m + JOIN profiles p ON p.couple_id = m.couple_id + WHERE m.id = match_archives.match_id + AND p.id = auth.uid() + ) + ); + +-- Users can delete their own archives (for unarchiving) +CREATE POLICY "Users can unarchive matches" + ON match_archives FOR DELETE + USING (user_id = auth.uid()); + +-- Add to realtime for live updates +ALTER PUBLICATION supabase_realtime ADD TABLE match_archives; + +-- Documentation +COMMENT ON TABLE match_archives IS 'Per-user match archives. Partner still sees the match. Delete record to unarchive.'; diff --git a/apps/supabase/migrations/20260110000000_add_daily_response_limit.sql b/apps/supabase/migrations/20260110000000_add_daily_response_limit.sql new file mode 100644 index 0000000..1dc3d8f --- /dev/null +++ b/apps/supabase/migrations/20260110000000_add_daily_response_limit.sql @@ -0,0 +1,77 @@ +-- Add daily response limit for non-premium users +-- This feature limits how many questions a non-premium user can answer per UTC day + +-- Add daily_response_limit column to app_config +ALTER TABLE app_config +ADD COLUMN daily_response_limit INTEGER DEFAULT 0; + +COMMENT ON COLUMN app_config.daily_response_limit IS 'Maximum number of questions a non-premium user can answer per UTC day. Set to 0 to disable daily limits.'; + +-- ============================================================================ +-- RPC FUNCTION: get_daily_response_limit_status +-- Returns the current daily response limit status for the authenticated user +-- ============================================================================ + +CREATE OR REPLACE FUNCTION get_daily_response_limit_status() +RETURNS TABLE( + responses_today INTEGER, + limit_value INTEGER, + remaining INTEGER, + reset_at TIMESTAMPTZ, + is_blocked BOOLEAN +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = 'public' +AS $$ +DECLARE + v_user_id UUID; + v_has_premium BOOLEAN; + v_limit INTEGER; + v_count INTEGER; + v_today_start TIMESTAMPTZ; + v_tomorrow_start TIMESTAMPTZ; +BEGIN + v_user_id := auth.uid(); + + -- Get limit from app_config + SELECT COALESCE(ac.daily_response_limit, 0) INTO v_limit + FROM app_config ac + LIMIT 1; + + -- If limit is 0, feature is disabled + IF v_limit = 0 THEN + RETURN QUERY SELECT 0, 0, 0, NULL::TIMESTAMPTZ, false; + RETURN; + END IF; + + -- Check if user has premium access (user or partner) + v_has_premium := public.has_premium_access(v_user_id); + + -- Premium users bypass the limit + IF v_has_premium THEN + RETURN QUERY SELECT 0, 0, 0, NULL::TIMESTAMPTZ, false; + RETURN; + END IF; + + -- Calculate UTC day boundaries + v_today_start := date_trunc('day', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'; + v_tomorrow_start := v_today_start + INTERVAL '1 day'; + + -- Count responses created today (UTC) + SELECT COUNT(*)::INTEGER INTO v_count + FROM responses r + WHERE r.user_id = v_user_id + AND r.created_at >= v_today_start + AND r.created_at < v_tomorrow_start; + + RETURN QUERY SELECT + v_count, + v_limit, + GREATEST(0, v_limit - v_count), + v_tomorrow_start, + (v_count >= v_limit); +END; +$$; + +COMMENT ON FUNCTION get_daily_response_limit_status() IS 'Returns daily response limit status for the current user. Premium users and disabled limits return is_blocked=false.'; diff --git a/apps/supabase/migrations/20260110000001_update_recommended_questions_daily_limit.sql b/apps/supabase/migrations/20260110000001_update_recommended_questions_daily_limit.sql new file mode 100644 index 0000000..23b240d --- /dev/null +++ b/apps/supabase/migrations/20260110000001_update_recommended_questions_daily_limit.sql @@ -0,0 +1,205 @@ +-- Update get_recommended_questions to enforce daily response limit for non-premium users +-- When daily limit is reached, return empty (old apps will show "All caught up") + +CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL) +RETURNS TABLE( + id UUID, + text TEXT, + partner_text TEXT, + is_two_part BOOLEAN, + pack_id UUID, + intensity INTEGER, + partner_answered BOOLEAN, + allowed_couple_genders TEXT[], + target_user_genders TEXT[] +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = 'public' +AS $$ +DECLARE + v_user_id UUID; + v_couple_id UUID; + v_partner_id UUID; + v_user_gender TEXT; + v_partner_gender TEXT; + v_couple_type TEXT; + v_has_premium BOOLEAN; + v_gap_threshold INTEGER; + v_gap_count INTEGER; + v_user_max_intensity INTEGER; + v_daily_limit INTEGER; + v_daily_count INTEGER; + v_today_start TIMESTAMPTZ; + v_tomorrow_start TIMESTAMPTZ; +BEGIN + v_user_id := auth.uid(); + + SELECT p.couple_id, p.gender, COALESCE(p.max_intensity, 5) + INTO v_couple_id, v_user_gender, v_user_max_intensity + FROM public.profiles p + WHERE p.id = v_user_id; + + IF v_couple_id IS NULL THEN + RETURN; + END IF; + + SELECT p.id, p.gender INTO v_partner_id, v_partner_gender + FROM public.profiles p + WHERE p.couple_id = v_couple_id AND p.id != v_user_id + LIMIT 1; + + -- Check premium access (used for both answer gap and daily limit) + v_has_premium := public.has_premium_access(v_user_id); + + -- Check daily response limit (only when not browsing a specific pack and user is non-premium) + IF v_partner_id IS NOT NULL AND target_pack_id IS NULL AND NOT v_has_premium THEN + -- Get daily limit from app_config + SELECT COALESCE(ac.daily_response_limit, 0) INTO v_daily_limit + FROM app_config ac + LIMIT 1; + + -- If daily limit > 0, check if user has reached it + IF v_daily_limit > 0 THEN + -- Calculate UTC day boundaries + v_today_start := date_trunc('day', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'; + v_tomorrow_start := v_today_start + INTERVAL '1 day'; + + -- Count responses created today (UTC) + SELECT COUNT(*)::INTEGER INTO v_daily_count + FROM responses r + WHERE r.user_id = v_user_id + AND r.created_at >= v_today_start + AND r.created_at < v_tomorrow_start; + + -- If over daily limit, return empty (old apps see "All caught up!") + IF v_daily_count >= v_daily_limit THEN + RETURN; + END IF; + END IF; + END IF; + + -- Check answer gap threshold (only when not browsing a specific pack) + IF v_partner_id IS NOT NULL AND target_pack_id IS NULL THEN + -- Get threshold from app_config + SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_gap_threshold + FROM app_config ac + LIMIT 1; + + -- If threshold > 0, check the gap + IF v_gap_threshold > 0 THEN + -- Count questions in ENABLED packs where user answered but partner hasn't + WITH enabled_packs AS ( + SELECT cp.pack_id + FROM couple_packs cp + WHERE cp.couple_id = v_couple_id AND cp.enabled = true + ) + SELECT COUNT(*)::integer INTO v_gap_count + FROM responses r + INNER JOIN questions q ON q.id = r.question_id + WHERE r.user_id = v_user_id + AND r.couple_id = v_couple_id + AND EXISTS (SELECT 1 FROM enabled_packs) + AND q.pack_id IN (SELECT ep.pack_id FROM enabled_packs ep) + AND NOT EXISTS ( + SELECT 1 FROM responses pr + WHERE pr.question_id = r.question_id + AND pr.user_id = v_partner_id + ); + + -- If over threshold, return empty (old apps see "All caught up!") + IF v_gap_count >= v_gap_threshold THEN + RETURN; + END IF; + END IF; + END IF; + + IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN + IF v_user_gender < v_partner_gender THEN + v_couple_type := v_user_gender || '+' || v_partner_gender; + ELSE + v_couple_type := v_partner_gender || '+' || v_user_gender; + END IF; + END IF; + + RETURN QUERY + WITH user_config_exists AS ( + SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1 + ), + active_packs AS ( + SELECT target_pack_id AS pack_id + WHERE target_pack_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM public.question_packs qp + WHERE qp.id = target_pack_id + AND (qp.is_premium = FALSE OR v_has_premium = TRUE) + ) + UNION ALL + SELECT cp.pack_id FROM public.couple_packs cp + INNER JOIN public.question_packs qp ON qp.id = cp.pack_id + WHERE target_pack_id IS NULL + AND cp.couple_id = v_couple_id + AND cp.enabled = TRUE + AND (qp.is_premium = FALSE OR v_has_premium = TRUE) + AND EXISTS (SELECT 1 FROM user_config_exists) + UNION ALL + SELECT p.id AS pack_id FROM public.question_packs p + WHERE target_pack_id IS NULL + AND p.is_public = TRUE + AND (p.is_premium = FALSE OR v_has_premium = TRUE) + AND NOT EXISTS (SELECT 1 FROM user_config_exists) + ) + SELECT + q.id, + CASE + WHEN q.partner_text IS NOT NULL AND EXISTS ( + SELECT 1 FROM public.responses r + WHERE r.question_id = q.id + AND r.couple_id = v_couple_id + AND r.user_id != v_user_id + ) THEN q.partner_text + ELSE q.text + END as text, + q.partner_text, + q.partner_text IS NOT NULL as is_two_part, + q.pack_id, + q.intensity, + EXISTS ( + SELECT 1 FROM public.responses r + WHERE r.question_id = q.id + AND r.couple_id = v_couple_id + AND r.user_id != v_user_id + ) AS partner_answered, + q.allowed_couple_genders, + q.target_user_genders + FROM public.questions q + WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap) + AND NOT EXISTS ( + SELECT 1 FROM public.responses r + WHERE r.question_id = q.id + AND r.user_id = v_user_id + ) + AND ( + q.allowed_couple_genders IS NULL + OR v_couple_type IS NULL + OR v_couple_type = ANY(q.allowed_couple_genders) + ) + AND ( + q.target_user_genders IS NULL + OR v_user_gender = ANY(q.target_user_genders) + OR EXISTS ( + SELECT 1 FROM public.responses r + WHERE r.question_id = q.id + AND r.couple_id = v_couple_id + AND r.user_id != v_user_id + ) + ) + -- Filter by user's max_intensity preference + AND ( + q.intensity IS NULL + OR q.intensity <= v_user_max_intensity + ); +END; +$$; + +COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user filtered by their intensity preference. When daily limit or answer gap threshold is exceeded for non-premium users, returns empty.'; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index c254adf..18e0100 100755 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -11,69 +11,69 @@ export interface Profile { /** RSA public key in JWK format for E2EE */ public_key_jwk?: Record | null; } - -// Couple pairing -export interface Couple { - id: string; - invite_code: string; - created_at: string; -} - -// Question pack -export interface QuestionPack { - id: string; - name: string; - description: string | null; - icon: string | null; - is_premium: boolean; - is_public: boolean; - sort_order: number; - created_at: string; -} - -// Individual question -export interface Question { - id: string; - pack_id: string; - text: string; - partner_text?: string | null; - intensity: 1 | 2 | 3 | 4 | 5; - allowed_couple_genders?: string[] | null; - target_user_genders?: string[] | null; - created_at: string; -} - -// User's answer type -export type AnswerType = 'yes' | 'no' | 'maybe'; - -// Match type when both partners agree -export type MatchType = 'yes_yes' | 'yes_maybe' | 'maybe_maybe'; - -// User response to a question -export interface Response { - id: string; - user_id: string; - question_id: string; - couple_id: string; - answer: AnswerType; - created_at: string; -} - -// Match between partners -export interface Match { - id: string; - couple_id: string; - question_id: string; - match_type: MatchType; - is_new: boolean; - created_at: string; -} - -// Extended match with question details -export interface MatchWithQuestion extends Match { - question: Question; -} - + +// Couple pairing +export interface Couple { + id: string; + invite_code: string; + created_at: string; +} + +// Question pack +export interface QuestionPack { + id: string; + name: string; + description: string | null; + icon: string | null; + is_premium: boolean; + is_public: boolean; + sort_order: number; + created_at: string; +} + +// Individual question +export interface Question { + id: string; + pack_id: string; + text: string; + partner_text?: string | null; + intensity: 1 | 2 | 3 | 4 | 5; + allowed_couple_genders?: string[] | null; + target_user_genders?: string[] | null; + created_at: string; +} + +// User's answer type +export type AnswerType = 'yes' | 'no' | 'maybe'; + +// Match type when both partners agree +export type MatchType = 'yes_yes' | 'yes_maybe' | 'maybe_maybe'; + +// User response to a question +export interface Response { + id: string; + user_id: string; + question_id: string; + couple_id: string; + answer: AnswerType; + created_at: string; +} + +// Match between partners +export interface Match { + id: string; + couple_id: string; + question_id: string; + match_type: MatchType; + is_new: boolean; + created_at: string; +} + +// Extended match with question details +export interface MatchWithQuestion extends Match { + question: Question; +} + // Pack with progress info export interface PackProgress { pack: QuestionPack; @@ -130,6 +130,14 @@ export interface Message { category?: 'Neutral' | 'Romantic' | 'Playful' | 'Explicit' | string | null; } +// Match archive (per-user) +export interface MatchArchive { + id: string; + match_id: string; + user_id: string; + archived_at: string; +} + // Master key for admin E2EE access export interface MasterKey { id: string;