Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0f1700e40 | ||
|
|
595d6bd394 | ||
|
|
fc515a19b0 | ||
|
|
b933c3c421 | ||
|
|
aff0bc0888 | ||
|
|
b60b72a289 |
No files matched your search
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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<example>\\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<component implementation>\\nassistant: \"Now let me use the react-native-reviewer agent to review this implementation for best practices and maintainability.\"\\n</example>\\n\\n<example>\\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<refactored component>\\nassistant: \"I'll now launch the react-native-reviewer agent to validate this refactoring follows React Native best practices.\"\\n</example>\\n\\n<example>\\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</example>"
|
||||||
|
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.
|
||||||
@@ -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<example>\\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<commentary>\\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</commentary>\\nassistant: \"Now let me use the test-agent to identify testing opportunities and run any existing tests.\"\\n</example>\\n\\n<example>\\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<commentary>\\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</commentary>\\n</example>\\n\\n<example>\\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<commentary>\\nSince tests are failing, use the Task tool to launch the test-agent to diagnose the failures and implement fixes.\\n</commentary>\\n</example>\\n\\n<example>\\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<commentary>\\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</commentary>\\nassistant: \"Now let me use the test-agent to run the related tests and ensure the changes are properly covered.\"\\n</example>"
|
||||||
|
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="<pattern>"`
|
||||||
|
- 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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -3,5 +3,10 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"Bash(wc:*)"
|
"Bash(wc:*)"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"enabledMcpjsonServers": [
|
||||||
|
"sauci-prod",
|
||||||
|
"sauci-non-prod"
|
||||||
|
],
|
||||||
|
"enableAllProjectMcpServers": true
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ node_modules/
|
|||||||
.pnp
|
.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
|
||||||
|
tmp/
|
||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|||||||
@@ -2,15 +2,24 @@ FROM node:20-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy root package files and workspace configs
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
COPY turbo.json ./
|
COPY turbo.json ./
|
||||||
|
|
||||||
|
# Copy all workspace package.json files
|
||||||
COPY apps/admin/package.json ./apps/admin/
|
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/
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
|
|
||||||
|
# Install all dependencies at root level (workspaces)
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy all source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Build shared package
|
||||||
RUN npx turbo run build --filter=@sauci/shared
|
RUN npx turbo run build --filter=@sauci/shared
|
||||||
|
|
||||||
WORKDIR /app/apps/admin
|
WORKDIR /app/apps/admin
|
||||||
|
|||||||
@@ -23,4 +23,6 @@ services:
|
|||||||
- ./vite.config.ts:/app/apps/admin/vite.config.ts
|
- ./vite.config.ts:/app/apps/admin/vite.config.ts
|
||||||
- ./tsconfig.json:/app/apps/admin/tsconfig.json
|
- ./tsconfig.json:/app/apps/admin/tsconfig.json
|
||||||
- ../../packages/shared/src:/app/packages/shared/src
|
- ../../packages/shared/src:/app/packages/shared/src
|
||||||
|
# Preserve node_modules from container (don't override with host)
|
||||||
|
- /app/node_modules
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -34,7 +34,6 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-icons": "^4.12.0",
|
"react-icons": "^4.12.0",
|
||||||
"react-router-dom": "^6.21.0",
|
"react-router-dom": "^6.21.0",
|
||||||
|
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"sonner": "^1.3.1",
|
"sonner": "^1.3.1",
|
||||||
"tailwind-merge": "^2.2.0"
|
"tailwind-merge": "^2.2.0"
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { DashboardPage } from '@/pages/DashboardPage';
|
|||||||
import { CategoriesPage } from '@/pages/content/CategoriesPage';
|
import { CategoriesPage } from '@/pages/content/CategoriesPage';
|
||||||
import { PacksPage } from '@/pages/content/PacksPage';
|
import { PacksPage } from '@/pages/content/PacksPage';
|
||||||
import { QuestionsPage } from '@/pages/content/QuestionsPage';
|
import { QuestionsPage } from '@/pages/content/QuestionsPage';
|
||||||
|
import { DarePacksPage } from '@/pages/content/DarePacksPage';
|
||||||
|
import { DaresPage } from '@/pages/content/DaresPage';
|
||||||
import { UsersPage } from '@/pages/users/UsersPage';
|
import { UsersPage } from '@/pages/users/UsersPage';
|
||||||
import { UserDetailPage } from '@/pages/users/UserDetailPage';
|
import { UserDetailPage } from '@/pages/users/UserDetailPage';
|
||||||
import { MatchChatPage } from '@/pages/users/MatchChatPage';
|
import { MatchChatPage } from '@/pages/users/MatchChatPage';
|
||||||
@@ -64,6 +66,22 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dare-packs"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_PACKS}>
|
||||||
|
<DarePacksPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dare-packs/:packId/dares"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
|
||||||
|
<DaresPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* User management */}
|
{/* User management */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Home,
|
Home,
|
||||||
Activity,
|
Activity,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
@@ -47,6 +48,12 @@ const contentNav: NavItem[] = [
|
|||||||
icon: <LayoutGrid className="h-5 w-5" />,
|
icon: <LayoutGrid className="h-5 w-5" />,
|
||||||
requiredPermission: PERMISSION_KEYS.MANAGE_CATEGORIES,
|
requiredPermission: PERMISSION_KEYS.MANAGE_CATEGORIES,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Dare Packs',
|
||||||
|
href: '/dare-packs',
|
||||||
|
icon: <Zap className="h-5 w-5" />,
|
||||||
|
requiredPermission: PERMISSION_KEYS.MANAGE_PACKS,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const usersNav: NavItem[] = [
|
const usersNav: NavItem[] = [
|
||||||
|
|||||||
@@ -1,48 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { IconType } from 'react-icons';
|
import type { IconType } from 'react-icons';
|
||||||
import {
|
import * as Io5Icons from 'react-icons/io5';
|
||||||
IoHeartOutline,
|
|
||||||
IoHeartHalfOutline,
|
|
||||||
IoHeartCircleOutline,
|
|
||||||
IoChatbubblesOutline,
|
|
||||||
IoChatboxOutline,
|
|
||||||
IoMailOutline,
|
|
||||||
IoFlowerOutline,
|
|
||||||
IoWineOutline,
|
|
||||||
IoRestaurantOutline,
|
|
||||||
IoCafeOutline,
|
|
||||||
IoAirplaneOutline,
|
|
||||||
IoCarOutline,
|
|
||||||
IoCompassOutline,
|
|
||||||
IoMapOutline,
|
|
||||||
IoHomeOutline,
|
|
||||||
IoPeopleOutline,
|
|
||||||
IoPersonOutline,
|
|
||||||
IoEyeOffOutline,
|
|
||||||
IoKeyOutline,
|
|
||||||
IoLockClosedOutline,
|
|
||||||
IoDiceOutline,
|
|
||||||
IoGiftOutline,
|
|
||||||
IoSparklesOutline,
|
|
||||||
IoStarOutline,
|
|
||||||
IoFlameOutline,
|
|
||||||
IoFlashOutline,
|
|
||||||
IoMoonOutline,
|
|
||||||
IoSunnyOutline,
|
|
||||||
IoFlagOutline,
|
|
||||||
IoCalendarOutline,
|
|
||||||
IoCheckboxOutline,
|
|
||||||
IoTrophyOutline,
|
|
||||||
IoLayersOutline,
|
|
||||||
IoCubeOutline,
|
|
||||||
IoFolderOutline,
|
|
||||||
IoBookmarkOutline,
|
|
||||||
IoBulbOutline,
|
|
||||||
IoColorWandOutline,
|
|
||||||
IoSyncOutline,
|
|
||||||
IoRefreshOutline,
|
|
||||||
IoBriefcaseOutline,
|
|
||||||
} from 'react-icons/io5';
|
|
||||||
import { Button } from './button';
|
import { Button } from './button';
|
||||||
import { Input } from './input';
|
import { Input } from './input';
|
||||||
|
|
||||||
@@ -51,68 +9,28 @@ interface IconOption {
|
|||||||
Icon: IconType;
|
Icon: IconType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICON_OPTIONS: IconOption[] = [
|
/**
|
||||||
// Relationships & Love
|
* Convert PascalCase component name to kebab-case Ionicon name.
|
||||||
{ name: 'heart-outline', Icon: IoHeartOutline },
|
* e.g., "IoHeartOutline" -> "heart-outline"
|
||||||
{ name: 'heart-half-outline', Icon: IoHeartHalfOutline },
|
*/
|
||||||
{ name: 'heart-circle-outline', Icon: IoHeartCircleOutline },
|
function toKebabCase(componentName: string): string {
|
||||||
|
// Remove "Io" prefix
|
||||||
|
const withoutPrefix = componentName.slice(2);
|
||||||
|
// Convert PascalCase to kebab-case
|
||||||
|
return withoutPrefix
|
||||||
|
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
||||||
|
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
// Communication
|
// Build icon options from all Io5 icons dynamically
|
||||||
{ name: 'chatbubbles-outline', Icon: IoChatbubblesOutline },
|
const ICON_OPTIONS: IconOption[] = Object.entries(Io5Icons)
|
||||||
{ name: 'chatbox-outline', Icon: IoChatboxOutline },
|
.filter(([name]) => name.startsWith('Io'))
|
||||||
{ name: 'mail-outline', Icon: IoMailOutline },
|
.map(([componentName, Icon]) => ({
|
||||||
|
name: toKebabCase(componentName),
|
||||||
// Romance & Dates
|
Icon: Icon as IconType,
|
||||||
{ name: 'flower-outline', Icon: IoFlowerOutline },
|
}))
|
||||||
{ name: 'wine-outline', Icon: IoWineOutline },
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
{ name: 'restaurant-outline', Icon: IoRestaurantOutline },
|
|
||||||
{ name: 'cafe-outline', Icon: IoCafeOutline },
|
|
||||||
|
|
||||||
// Adventure & Travel
|
|
||||||
{ name: 'airplane-outline', Icon: IoAirplaneOutline },
|
|
||||||
{ name: 'car-outline', Icon: IoCarOutline },
|
|
||||||
{ name: 'compass-outline', Icon: IoCompassOutline },
|
|
||||||
{ name: 'map-outline', Icon: IoMapOutline },
|
|
||||||
|
|
||||||
// Home & Family
|
|
||||||
{ name: 'home-outline', Icon: IoHomeOutline },
|
|
||||||
{ name: 'people-outline', Icon: IoPeopleOutline },
|
|
||||||
{ name: 'person-outline', Icon: IoPersonOutline },
|
|
||||||
|
|
||||||
// Mystery & Secrets
|
|
||||||
{ name: 'eye-off-outline', Icon: IoEyeOffOutline },
|
|
||||||
{ name: 'key-outline', Icon: IoKeyOutline },
|
|
||||||
{ name: 'lock-closed-outline', Icon: IoLockClosedOutline },
|
|
||||||
|
|
||||||
// Fun & Games
|
|
||||||
{ name: 'dice-outline', Icon: IoDiceOutline },
|
|
||||||
{ name: 'gift-outline', Icon: IoGiftOutline },
|
|
||||||
{ name: 'sparkles-outline', Icon: IoSparklesOutline },
|
|
||||||
{ name: 'star-outline', Icon: IoStarOutline },
|
|
||||||
|
|
||||||
// Intimacy
|
|
||||||
{ name: 'flame-outline', Icon: IoFlameOutline },
|
|
||||||
{ name: 'flash-outline', Icon: IoFlashOutline },
|
|
||||||
{ name: 'moon-outline', Icon: IoMoonOutline },
|
|
||||||
{ name: 'sunny-outline', Icon: IoSunnyOutline },
|
|
||||||
|
|
||||||
// Goals & Planning
|
|
||||||
{ name: 'flag-outline', Icon: IoFlagOutline },
|
|
||||||
{ name: 'calendar-outline', Icon: IoCalendarOutline },
|
|
||||||
{ name: 'checkbox-outline', Icon: IoCheckboxOutline },
|
|
||||||
{ name: 'trophy-outline', Icon: IoTrophyOutline },
|
|
||||||
|
|
||||||
// General
|
|
||||||
{ name: 'layers-outline', Icon: IoLayersOutline },
|
|
||||||
{ name: 'cube-outline', Icon: IoCubeOutline },
|
|
||||||
{ name: 'folder-outline', Icon: IoFolderOutline },
|
|
||||||
{ name: 'bookmark-outline', Icon: IoBookmarkOutline },
|
|
||||||
{ name: 'bulb-outline', Icon: IoBulbOutline },
|
|
||||||
{ name: 'color-wand-outline', Icon: IoColorWandOutline },
|
|
||||||
{ name: 'sync-outline', Icon: IoSyncOutline },
|
|
||||||
{ name: 'refresh-outline', Icon: IoRefreshOutline },
|
|
||||||
{ name: 'briefcase-outline', Icon: IoBriefcaseOutline },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ICONS_BY_NAME = ICON_OPTIONS.reduce<Record<string, IconOption>>((acc, option) => {
|
const ICONS_BY_NAME = ICON_OPTIONS.reduce<Record<string, IconOption>>((acc, option) => {
|
||||||
acc[option.name] = option;
|
acc[option.name] = option;
|
||||||
@@ -168,8 +86,9 @@ export function IconPicker({ value, onChange }: IconPickerProps) {
|
|||||||
return ICON_OPTIONS.filter((option) => option.name.includes(normalized));
|
return ICON_OPTIONS.filter((option) => option.name.includes(normalized));
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
const SelectedIcon = ICONS_BY_NAME[value]?.Icon;
|
const selectedOption = ICONS_BY_NAME[value];
|
||||||
const buttonLabel = SelectedIcon ? value : value ? 'Legacy icon' : 'Select an icon';
|
const SelectedIcon = selectedOption?.Icon;
|
||||||
|
const buttonLabel = selectedOption ? value : value ? 'Legacy icon' : 'Select an icon';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={containerRef}>
|
<div className="relative" ref={containerRef}>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type PaginationControlsProps = {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalCount: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
showPageSize?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PaginationControls({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalCount,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
pageSizeOptions = [10, 25, 50],
|
||||||
|
showPageSize = true,
|
||||||
|
className,
|
||||||
|
}: PaginationControlsProps) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
const safePage = Math.min(Math.max(page, 1), totalPages);
|
||||||
|
const start = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1;
|
||||||
|
const end = totalCount === 0 ? 0 : Math.min(safePage * pageSize, totalCount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-2 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{totalCount === 0
|
||||||
|
? 'No results'
|
||||||
|
: `Showing ${start} to ${end} of ${totalCount}`}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{showPageSize && onPageSizeChange && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs uppercase tracking-wide text-muted-foreground">Rows</span>
|
||||||
|
<Select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onValueChange={(value) => onPageSizeChange(Number(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[90px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{pageSizeOptions.map((size) => (
|
||||||
|
<SelectItem key={size} value={String(size)}>
|
||||||
|
{size}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(Math.max(1, safePage - 1))}
|
||||||
|
disabled={safePage <= 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||||
|
Prev
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Page {safePage} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(Math.min(totalPages, safePage + 1))}
|
||||||
|
disabled={safePage >= totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="ml-1 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,16 @@ export interface AiConfig {
|
|||||||
classifier_enabled: boolean | null;
|
classifier_enabled: boolean | null;
|
||||||
classifier_model: string | null;
|
classifier_model: string | null;
|
||||||
classifier_prompt: 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_at: string | null;
|
||||||
updated_by: string | null;
|
updated_by: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { supabase } from '@/config';
|
|||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
id: string;
|
id: string;
|
||||||
answer_gap_threshold: number;
|
answer_gap_threshold: number;
|
||||||
|
daily_response_limit: number;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
updated_by: string | null;
|
updated_by: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,10 +86,10 @@ export const auditedSupabase = {
|
|||||||
/**
|
/**
|
||||||
* Update a single record with audit logging
|
* Update a single record with audit logging
|
||||||
*/
|
*/
|
||||||
async update<T extends { id: string }>(
|
async update<T extends object = Record<string, unknown>>(
|
||||||
table: string,
|
table: string,
|
||||||
id: string,
|
id: string,
|
||||||
updates: Partial<T>
|
updates: Partial<T> | Record<string, unknown>
|
||||||
): Promise<SingleResult<T>> {
|
): Promise<SingleResult<T>> {
|
||||||
// Fetch old values before update
|
// Fetch old values before update
|
||||||
let oldValues: Record<string, unknown> | null = null;
|
let oldValues: Record<string, unknown> | null = null;
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ import { useState, useCallback } from 'react';
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function useEntityForm<
|
export function useEntityForm<
|
||||||
TFormData extends Record<string, unknown>,
|
TFormData extends object,
|
||||||
TEntity extends Record<string, unknown> = TFormData
|
TEntity extends object = TFormData
|
||||||
>(
|
>(
|
||||||
initialFormData: TFormData,
|
initialFormData: TFormData,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { KeyboardEvent } from 'react';
|
||||||
import { useAiConfig, AiConfig, CouncilGenerator } from '@/hooks/useAiConfig';
|
import { useAiConfig, AiConfig, CouncilGenerator } from '@/hooks/useAiConfig';
|
||||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Separator } from '@/components/ui/separator';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
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 { toast } from 'sonner';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { ModelCombobox } from '@/components/ModelCombobox';
|
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() {
|
export function AiSettingsPage() {
|
||||||
const { hasPermission, isSuperAdmin } = useAuth();
|
const { hasPermission, isSuperAdmin } = useAuth();
|
||||||
const { config, loading, error, updateConfig, refetch } = useAiConfig();
|
const { config, loading, error, updateConfig, refetch } = useAiConfig();
|
||||||
@@ -24,6 +49,8 @@ export function AiSettingsPage() {
|
|||||||
const [showApiKey, setShowApiKey] = useState(false);
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [hasChanges, setHasChanges] = 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
|
// Permission check - only super admins or those with MANAGE_AI_CONFIG can access
|
||||||
const canManage = isSuperAdmin || hasPermission(PERMISSION_KEYS.MANAGE_AI_CONFIG);
|
const canManage = isSuperAdmin || hasPermission(PERMISSION_KEYS.MANAGE_AI_CONFIG);
|
||||||
@@ -51,6 +78,16 @@ export function AiSettingsPage() {
|
|||||||
classifier_enabled: config.classifier_enabled ?? true,
|
classifier_enabled: config.classifier_enabled ?? true,
|
||||||
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
||||||
classifier_prompt: config.classifier_prompt || '',
|
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);
|
setHasChanges(false);
|
||||||
}
|
}
|
||||||
@@ -61,6 +98,46 @@ export function AiSettingsPage() {
|
|||||||
setHasChanges(true);
|
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<HTMLInputElement>,
|
||||||
|
field: ListField,
|
||||||
|
value: string,
|
||||||
|
clearValue: (next: string) => void
|
||||||
|
) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleAddListItem(field, value, clearValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -99,6 +176,16 @@ export function AiSettingsPage() {
|
|||||||
classifier_enabled: config.classifier_enabled ?? true,
|
classifier_enabled: config.classifier_enabled ?? true,
|
||||||
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
||||||
classifier_prompt: config.classifier_prompt || '',
|
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);
|
setHasChanges(false);
|
||||||
}
|
}
|
||||||
@@ -305,6 +392,223 @@ export function AiSettingsPage() {
|
|||||||
Instructions for the AI classifier.
|
Instructions for the AI classifier.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Enable Heuristic Pre-Filter</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Skip AI calls for low-risk messages using simple rules
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={formData.heuristics_enabled ?? false}
|
||||||
|
onCheckedChange={(checked) => handleChange('heuristics_enabled', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="heuristic-min-length">Minimum Text Length</Label>
|
||||||
|
<Input
|
||||||
|
id="heuristic-min-length"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={formData.heuristic_min_text_length ?? 12}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange(
|
||||||
|
'heuristic_min_text_length',
|
||||||
|
e.target.value === '' ? null : Number(e.target.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Skip AI for shorter messages without media
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="heuristic-whitelist-max">Whitelist Max Length</Label>
|
||||||
|
<Input
|
||||||
|
id="heuristic-whitelist-max"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={formData.heuristic_whitelist_max_length ?? 30}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange(
|
||||||
|
'heuristic_whitelist_max_length',
|
||||||
|
e.target.value === '' ? null : Number(e.target.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Only apply whitelist to messages below this length
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Skip If No Letters/Numbers</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Treat emoji-only or punctuation-only messages as safe
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={formData.heuristic_skip_if_no_alnum ?? true}
|
||||||
|
onCheckedChange={(checked) => handleChange('heuristic_skip_if_no_alnum', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Skip Media Without Text</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Skip AI when only media is present
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={formData.heuristic_skip_media_without_text ?? false}
|
||||||
|
onCheckedChange={(checked) => handleChange('heuristic_skip_media_without_text', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Use Default Whitelist</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Include built-in common phrases
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={formData.heuristic_use_default_whitelist ?? true}
|
||||||
|
onCheckedChange={(checked) => handleChange('heuristic_use_default_whitelist', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Use Default Keywords</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Include built-in safety keywords
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={formData.heuristic_use_default_keywords ?? true}
|
||||||
|
onCheckedChange={(checked) => handleChange('heuristic_use_default_keywords', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Record Heuristic Reason</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Store skip reason in flag_reason for debugging
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={formData.heuristic_record_reason ?? false}
|
||||||
|
onCheckedChange={(checked) => handleChange('heuristic_record_reason', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Custom Whitelist</Label>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
value={whitelistInput}
|
||||||
|
onChange={(e) => setWhitelistInput(e.target.value)}
|
||||||
|
onKeyDown={(event) =>
|
||||||
|
handleListKeyDown(event, 'heuristic_whitelist', whitelistInput, setWhitelistInput)
|
||||||
|
}
|
||||||
|
placeholder="Add phrase"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
handleAddListItem('heuristic_whitelist', whitelistInput, setWhitelistInput)
|
||||||
|
}
|
||||||
|
disabled={!whitelistInput.trim()}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{whitelistItems.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{whitelistItems.map((item) => (
|
||||||
|
<Badge key={item} variant="secondary" className="flex items-center gap-1">
|
||||||
|
<span className="font-mono text-xs">{item}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveListItem('heuristic_whitelist', item)}
|
||||||
|
className="rounded-full p-0.5 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={`Remove ${item}`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">No phrases added yet.</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Messages matching these phrases are marked safe
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Keyword Triggers</Label>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
value={keywordInput}
|
||||||
|
onChange={(e) => setKeywordInput(e.target.value)}
|
||||||
|
onKeyDown={(event) =>
|
||||||
|
handleListKeyDown(event, 'heuristic_keyword_triggers', keywordInput, setKeywordInput)
|
||||||
|
}
|
||||||
|
placeholder="Add keyword"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
handleAddListItem('heuristic_keyword_triggers', keywordInput, setKeywordInput)
|
||||||
|
}
|
||||||
|
disabled={!keywordInput.trim()}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{keywordItems.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{keywordItems.map((item) => (
|
||||||
|
<Badge key={item} variant="secondary" className="flex items-center gap-1">
|
||||||
|
<span className="font-mono text-xs">{item}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveListItem('heuristic_keyword_triggers', item)}
|
||||||
|
className="rounded-full p-0.5 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={`Remove ${item}`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">No keywords added yet.</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
If any term appears, the message will be sent to AI
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function AppSettingsPage() {
|
|||||||
if (config) {
|
if (config) {
|
||||||
setFormData({
|
setFormData({
|
||||||
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
||||||
|
daily_response_limit: config.daily_response_limit ?? 0,
|
||||||
});
|
});
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
}
|
}
|
||||||
@@ -59,6 +60,7 @@ export function AppSettingsPage() {
|
|||||||
if (config) {
|
if (config) {
|
||||||
setFormData({
|
setFormData({
|
||||||
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
||||||
|
daily_response_limit: config.daily_response_limit ?? 0,
|
||||||
});
|
});
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
}
|
}
|
||||||
@@ -176,6 +178,43 @@ export function AppSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Daily Response Limit */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Daily Response Limit (Free Users)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Limit the number of questions non-premium users can answer per day (UTC 00:00-23:59)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dailyLimit">Daily Response Limit</Label>
|
||||||
|
<Input
|
||||||
|
id="dailyLimit"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="1000"
|
||||||
|
value={formData.daily_response_limit ?? 0}
|
||||||
|
onChange={(e) => handleChange('daily_response_limit', parseInt(e.target.value) || 0)}
|
||||||
|
className="max-w-[200px]"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Maximum number of questions a non-premium user can answer per UTC day.
|
||||||
|
Premium users (via personal or partner subscription) bypass this limit.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Set to <strong>0</strong> to disable daily limits entirely.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Limit resets daily at 00:00 UTC. Editing existing answers does not count toward the limit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { supabase } from '@/config';
|
import { supabase } from '@/config';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -34,28 +35,42 @@ const actionColors: Record<string, string> = {
|
|||||||
export function AuditLogsPage() {
|
export function AuditLogsPage() {
|
||||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(50);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
|
const { data, error, count } = await supabase
|
||||||
|
.from('audit_logs')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setLogs(data || []);
|
||||||
|
setTotalCount(count || 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load audit logs:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, pageSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchLogs = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('audit_logs')
|
|
||||||
.select('*')
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(100);
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
setLogs(data || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load audit logs:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchLogs();
|
fetchLogs();
|
||||||
}, []);
|
}, [fetchLogs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -141,6 +156,19 @@ export function AuditLogsPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||||
@@ -87,6 +89,9 @@ export function FeedbackPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
|
||||||
|
|
||||||
// Detail Dialog State
|
// Detail Dialog State
|
||||||
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
|
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
|
||||||
@@ -140,6 +145,11 @@ export function FeedbackPage() {
|
|||||||
fetchFeedback();
|
fetchFeedback();
|
||||||
}, [fetchFeedback]);
|
}, [fetchFeedback]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [search, typeFilter, statusFilter]);
|
||||||
|
|
||||||
|
|
||||||
const handleOpenDetail = (item: Feedback) => {
|
const handleOpenDetail = (item: Feedback) => {
|
||||||
setSelectedFeedback(item);
|
setSelectedFeedback(item);
|
||||||
setEditStatus(item.status);
|
setEditStatus(item.status);
|
||||||
@@ -187,6 +197,20 @@ export function FeedbackPage() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const totalCount = filteredFeedback.length;
|
||||||
|
const paginatedFeedback = filteredFeedback.slice(
|
||||||
|
(page - 1) * pageSize,
|
||||||
|
page * pageSize
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
|
|
||||||
const getTypeBadge = (type: FeedbackType) => {
|
const getTypeBadge = (type: FeedbackType) => {
|
||||||
const config = feedbackTypeConfig[type];
|
const config = feedbackTypeConfig[type];
|
||||||
return (
|
return (
|
||||||
@@ -295,7 +319,7 @@ export function FeedbackPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredFeedback.map((item) => (
|
paginatedFeedback.map((item) => (
|
||||||
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleOpenDetail(item)}>
|
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleOpenDetail(item)}>
|
||||||
<TableCell>{getTypeBadge(item.type)}</TableCell>
|
<TableCell>{getTypeBadge(item.type)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -329,6 +353,19 @@ export function FeedbackPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filteredFeedback.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Detail Dialog */}
|
{/* Detail Dialog */}
|
||||||
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
|
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
|
||||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { supabase } from '@/config';
|
import { supabase } from '@/config';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -65,10 +66,16 @@ export function FlaggedMessagesPage() {
|
|||||||
// AI Flagged state
|
// AI Flagged state
|
||||||
const [aiMessages, setAiMessages] = useState<FlaggedMessage[]>([]);
|
const [aiMessages, setAiMessages] = useState<FlaggedMessage[]>([]);
|
||||||
const [aiLoading, setAiLoading] = useState(true);
|
const [aiLoading, setAiLoading] = useState(true);
|
||||||
|
const [aiPage, setAiPage] = useState(1);
|
||||||
|
const [aiPageSize, setAiPageSize] = useState(25);
|
||||||
|
const [aiTotal, setAiTotal] = useState(0);
|
||||||
|
|
||||||
// User Reports state
|
// User Reports state
|
||||||
const [userReports, setUserReports] = useState<UserReport[]>([]);
|
const [userReports, setUserReports] = useState<UserReport[]>([]);
|
||||||
const [reportsLoading, setReportsLoading] = useState(true);
|
const [reportsLoading, setReportsLoading] = useState(true);
|
||||||
|
const [reportsPage, setReportsPage] = useState(1);
|
||||||
|
const [reportsPageSize, setReportsPageSize] = useState(25);
|
||||||
|
const [reportsTotal, setReportsTotal] = useState(0);
|
||||||
|
|
||||||
// Shared state
|
// Shared state
|
||||||
const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null);
|
const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null);
|
||||||
@@ -82,30 +89,38 @@ export function FlaggedMessagesPage() {
|
|||||||
const [describingInline, setDescribingInline] = useState<string | null>(null);
|
const [describingInline, setDescribingInline] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch AI flagged messages
|
// Fetch AI flagged messages
|
||||||
const fetchAiMessages = async () => {
|
const fetchAiMessages = useCallback(async () => {
|
||||||
setAiLoading(true);
|
setAiLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const from = (aiPage - 1) * aiPageSize;
|
||||||
|
const to = from + aiPageSize - 1;
|
||||||
|
|
||||||
|
const { data, error, count } = await supabase
|
||||||
.from('messages')
|
.from('messages')
|
||||||
.select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type')
|
.select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type', { count: 'exact' })
|
||||||
.eq('moderation_status', 'flagged')
|
.eq('moderation_status', 'flagged')
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setAiMessages(data || []);
|
setAiMessages(data || []);
|
||||||
|
setAiTotal(count || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load flagged messages:', error);
|
console.error('Failed to load flagged messages:', error);
|
||||||
toast.error('Failed to load messages');
|
toast.error('Failed to load messages');
|
||||||
} finally {
|
} finally {
|
||||||
setAiLoading(false);
|
setAiLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [aiPage, aiPageSize]);
|
||||||
|
|
||||||
// Fetch user reports
|
// Fetch user reports
|
||||||
const fetchUserReports = async () => {
|
const fetchUserReports = useCallback(async () => {
|
||||||
setReportsLoading(true);
|
setReportsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const from = (reportsPage - 1) * reportsPageSize;
|
||||||
|
const to = from + reportsPageSize - 1;
|
||||||
|
|
||||||
|
const { data, error, count } = await supabase
|
||||||
.from('message_reports')
|
.from('message_reports')
|
||||||
.select(`
|
.select(`
|
||||||
id,
|
id,
|
||||||
@@ -116,24 +131,40 @@ export function FlaggedMessagesPage() {
|
|||||||
created_at,
|
created_at,
|
||||||
message:messages(id, user_id, match_id, content, media_path, media_type),
|
message:messages(id, user_id, match_id, content, media_path, media_type),
|
||||||
reporter_profile:profiles!reporter_id(name, email)
|
reporter_profile:profiles!reporter_id(name, email)
|
||||||
`)
|
`, { count: 'exact' })
|
||||||
.eq('status', 'pending')
|
.eq('status', 'pending')
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setUserReports((data || []) as unknown as UserReport[]);
|
setUserReports((data || []) as unknown as UserReport[]);
|
||||||
|
setReportsTotal(count || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load user reports:', error);
|
console.error('Failed to load user reports:', error);
|
||||||
toast.error('Failed to load reports');
|
toast.error('Failed to load reports');
|
||||||
} finally {
|
} finally {
|
||||||
setReportsLoading(false);
|
setReportsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [reportsPage, reportsPageSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAiMessages();
|
fetchAiMessages();
|
||||||
fetchUserReports();
|
fetchUserReports();
|
||||||
}, []);
|
}, [fetchAiMessages, fetchUserReports]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(aiTotal / aiPageSize));
|
||||||
|
if (aiPage > totalPages) {
|
||||||
|
setAiPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [aiPage, aiPageSize, aiTotal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(reportsTotal / reportsPageSize));
|
||||||
|
if (reportsPage > totalPages) {
|
||||||
|
setReportsPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [reportsPage, reportsPageSize, reportsTotal]);
|
||||||
|
|
||||||
// Custom media fetcher because supabase.functions.invoke is JSON-centric
|
// Custom media fetcher because supabase.functions.invoke is JSON-centric
|
||||||
const fetchDecryptedMedia = async (messageId: string) => {
|
const fetchDecryptedMedia = async (messageId: string) => {
|
||||||
@@ -296,8 +327,8 @@ export function FlaggedMessagesPage() {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast.success('Message marked as safe');
|
toast.success('Message marked as safe');
|
||||||
setAiMessages(prev => prev.filter(m => m.id !== messageId));
|
|
||||||
if (selectedMessage?.id === messageId) setSelectedMessage(null);
|
if (selectedMessage?.id === messageId) setSelectedMessage(null);
|
||||||
|
fetchAiMessages();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to update status');
|
toast.error('Failed to update status');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -316,8 +347,8 @@ export function FlaggedMessagesPage() {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast.success('Report dismissed');
|
toast.success('Report dismissed');
|
||||||
setUserReports(prev => prev.filter(r => r.id !== reportId));
|
|
||||||
if (selectedReport?.id === reportId) setSelectedReport(null);
|
if (selectedReport?.id === reportId) setSelectedReport(null);
|
||||||
|
fetchUserReports();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to dismiss report');
|
toast.error('Failed to dismiss report');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -336,8 +367,8 @@ export function FlaggedMessagesPage() {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast.success('Report marked as reviewed');
|
toast.success('Report marked as reviewed');
|
||||||
setUserReports(prev => prev.filter(r => r.id !== reportId));
|
|
||||||
if (selectedReport?.id === reportId) setSelectedReport(null);
|
if (selectedReport?.id === reportId) setSelectedReport(null);
|
||||||
|
fetchUserReports();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to update report');
|
toast.error('Failed to update report');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -491,6 +522,19 @@ export function FlaggedMessagesPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{aiTotal > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={aiPage}
|
||||||
|
pageSize={aiPageSize}
|
||||||
|
totalCount={aiTotal}
|
||||||
|
onPageChange={setAiPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setAiPage(1);
|
||||||
|
setAiPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="user-reports" className="space-y-4">
|
<TabsContent value="user-reports" className="space-y-4">
|
||||||
@@ -579,6 +623,19 @@ export function FlaggedMessagesPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{reportsTotal > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={reportsPage}
|
||||||
|
pageSize={reportsPageSize}
|
||||||
|
totalCount={reportsTotal}
|
||||||
|
onPageChange={setReportsPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setReportsPage(1);
|
||||||
|
setReportsPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
import { supabase } from '@/config';
|
import { supabase } from '@/config';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -64,6 +67,10 @@ export function RedemptionCodesPage() {
|
|||||||
const [codes, setCodes] = useState<RedemptionCode[]>([]);
|
const [codes, setCodes] = useState<RedemptionCode[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
|
||||||
// Add Code State
|
// Add Code State
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
@@ -79,41 +86,60 @@ export function RedemptionCodesPage() {
|
|||||||
const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null);
|
const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null);
|
||||||
const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]);
|
const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]);
|
||||||
const [loadingRedemptions, setLoadingRedemptions] = useState(false);
|
const [loadingRedemptions, setLoadingRedemptions] = useState(false);
|
||||||
|
const [redemptionsPage, setRedemptionsPage] = useState(1);
|
||||||
|
const [redemptionsPageSize, setRedemptionsPageSize] = useState(10);
|
||||||
|
const [redemptionsTotal, setRedemptionsTotal] = useState(0);
|
||||||
|
|
||||||
// Initial load
|
|
||||||
useEffect(() => {
|
|
||||||
fetchCodes();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchCodes = async () => {
|
|
||||||
|
|
||||||
|
const fetchCodes = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
const trimmedSearch = search.trim();
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
.from('redemption_codes')
|
.from('redemption_codes')
|
||||||
.select('*')
|
.select('*', { count: 'exact' })
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (trimmedSearch) {
|
||||||
|
query = query.or(`code.ilike.%${trimmedSearch}%,description.ilike.%${trimmedSearch}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error, count } = await query.range(from, to);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setCodes(data || []);
|
setCodes(data || []);
|
||||||
|
setTotalCount(count || 0);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to load codes:', error);
|
console.error('Failed to load codes:', error);
|
||||||
toast.error("Failed to load redemption codes");
|
toast.error("Failed to load redemption codes");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [page, pageSize, search]);
|
||||||
|
|
||||||
const fetchRedemptions = async (codeId: string) => {
|
|
||||||
|
const fetchRedemptions = useCallback(async (codeId: string) => {
|
||||||
setLoadingRedemptions(true);
|
setLoadingRedemptions(true);
|
||||||
try {
|
try {
|
||||||
const { data: redemptionData, error: redemptionError } = await supabase
|
const from = (redemptionsPage - 1) * redemptionsPageSize;
|
||||||
|
const to = from + redemptionsPageSize - 1;
|
||||||
|
|
||||||
|
const { data: redemptionData, error: redemptionError, count } = await supabase
|
||||||
.from('code_redemptions')
|
.from('code_redemptions')
|
||||||
.select('id, user_id, redeemed_at')
|
.select('id, user_id, redeemed_at', { count: 'exact' })
|
||||||
.eq('code_id', codeId)
|
.eq('code_id', codeId)
|
||||||
.order('redeemed_at', { ascending: false });
|
.order('redeemed_at', { ascending: false })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
if (redemptionError) throw redemptionError;
|
if (redemptionError) throw redemptionError;
|
||||||
|
|
||||||
|
setRedemptionsTotal(count || 0);
|
||||||
|
|
||||||
if (redemptionData && redemptionData.length > 0) {
|
if (redemptionData && redemptionData.length > 0) {
|
||||||
const userIds = redemptionData.map(r => r.user_id);
|
const userIds = redemptionData.map(r => r.user_id);
|
||||||
const { data: profileData } = await supabase
|
const { data: profileData } = await supabase
|
||||||
@@ -135,7 +161,36 @@ export function RedemptionCodesPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingRedemptions(false);
|
setLoadingRedemptions(false);
|
||||||
}
|
}
|
||||||
};
|
}, [redemptionsPage, redemptionsPageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCodes();
|
||||||
|
}, [fetchCodes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewingCode) {
|
||||||
|
fetchRedemptions(viewingCode.id);
|
||||||
|
}
|
||||||
|
}, [viewingCode, fetchRedemptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(redemptionsTotal / redemptionsPageSize));
|
||||||
|
if (redemptionsPage > totalPages) {
|
||||||
|
setRedemptionsPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [redemptionsPage, redemptionsPageSize, redemptionsTotal]);
|
||||||
|
|
||||||
|
|
||||||
const handleAddCode = async () => {
|
const handleAddCode = async () => {
|
||||||
if (!formData.code.trim()) {
|
if (!formData.code.trim()) {
|
||||||
@@ -226,17 +281,12 @@ export function RedemptionCodesPage() {
|
|||||||
|
|
||||||
const handleViewRedemptions = (code: RedemptionCode) => {
|
const handleViewRedemptions = (code: RedemptionCode) => {
|
||||||
setViewingCode(code);
|
setViewingCode(code);
|
||||||
fetchRedemptions(code.id);
|
setRedemptionsPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredCodes = codes.filter(code => {
|
|
||||||
if (!search) return true;
|
const filteredCodes = codes;
|
||||||
const query = search.toLowerCase();
|
|
||||||
return (
|
|
||||||
code.code.toLowerCase().includes(query) ||
|
|
||||||
code.description?.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const getStatusBadge = (code: RedemptionCode) => {
|
const getStatusBadge = (code: RedemptionCode) => {
|
||||||
if (!code.is_active) {
|
if (!code.is_active) {
|
||||||
@@ -440,6 +490,20 @@ export function RedemptionCodesPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filteredCodes.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* View Redemptions Dialog */}
|
{/* View Redemptions Dialog */}
|
||||||
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
|
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
@@ -486,7 +550,21 @@ export function RedemptionCodesPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{redemptionsTotal > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={redemptionsPage}
|
||||||
|
pageSize={redemptionsPageSize}
|
||||||
|
totalCount={redemptionsTotal}
|
||||||
|
onPageChange={setRedemptionsPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setRedemptionsPage(1);
|
||||||
|
setRedemptionsPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
||||||
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
|
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -121,6 +122,18 @@ export function UserActivityPage() {
|
|||||||
const [matches, setMatches] = useState<MatchActivity[]>([]);
|
const [matches, setMatches] = useState<MatchActivity[]>([]);
|
||||||
const [messages, setMessages] = useState<MessageActivity[]>([]);
|
const [messages, setMessages] = useState<MessageActivity[]>([]);
|
||||||
const [signups, setSignups] = useState<SignupActivity[]>([]);
|
const [signups, setSignups] = useState<SignupActivity[]>([]);
|
||||||
|
const [responsesPage, setResponsesPage] = useState(1);
|
||||||
|
const [responsesPageSize, setResponsesPageSize] = useState(25);
|
||||||
|
const [responsesTotal, setResponsesTotal] = useState(0);
|
||||||
|
const [matchesPage, setMatchesPage] = useState(1);
|
||||||
|
const [matchesPageSize, setMatchesPageSize] = useState(25);
|
||||||
|
const [matchesTotal, setMatchesTotal] = useState(0);
|
||||||
|
const [messagesPage, setMessagesPage] = useState(1);
|
||||||
|
const [messagesPageSize, setMessagesPageSize] = useState(25);
|
||||||
|
const [messagesTotal, setMessagesTotal] = useState(0);
|
||||||
|
const [signupsPage, setSignupsPage] = useState(1);
|
||||||
|
const [signupsPageSize, setSignupsPageSize] = useState(25);
|
||||||
|
const [signupsTotal, setSignupsTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState({
|
const [loading, setLoading] = useState({
|
||||||
responses: true,
|
responses: true,
|
||||||
matches: true,
|
matches: true,
|
||||||
@@ -132,7 +145,10 @@ export function UserActivityPage() {
|
|||||||
const fetchResponses = useCallback(async () => {
|
const fetchResponses = useCallback(async () => {
|
||||||
setLoading(prev => ({ ...prev, responses: true }));
|
setLoading(prev => ({ ...prev, responses: true }));
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const from = (responsesPage - 1) * responsesPageSize;
|
||||||
|
const to = from + responsesPageSize - 1;
|
||||||
|
|
||||||
|
const { data, error, count } = await supabase
|
||||||
.from('responses')
|
.from('responses')
|
||||||
.select(`
|
.select(`
|
||||||
id, user_id, answer, created_at,
|
id, user_id, answer, created_at,
|
||||||
@@ -141,12 +157,14 @@ export function UserActivityPage() {
|
|||||||
id, text,
|
id, text,
|
||||||
pack:question_packs(name)
|
pack:question_packs(name)
|
||||||
)
|
)
|
||||||
`)
|
`, { count: 'exact' })
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(50);
|
.range(from, to);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
setResponsesTotal(count || 0);
|
||||||
|
|
||||||
// Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations)
|
// Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations)
|
||||||
const transformed: ResponseActivity[] = (data || []).map((item: any) => ({
|
const transformed: ResponseActivity[] = (data || []).map((item: any) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -167,23 +185,28 @@ export function UserActivityPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(prev => ({ ...prev, responses: false }));
|
setLoading(prev => ({ ...prev, responses: false }));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [responsesPage, responsesPageSize]);
|
||||||
|
|
||||||
// Fetch matches with couple profiles
|
// Fetch matches with couple profiles
|
||||||
const fetchMatches = useCallback(async () => {
|
const fetchMatches = useCallback(async () => {
|
||||||
setLoading(prev => ({ ...prev, matches: true }));
|
setLoading(prev => ({ ...prev, matches: true }));
|
||||||
try {
|
try {
|
||||||
const { data: matchesData, error: matchesError } = await supabase
|
const from = (matchesPage - 1) * matchesPageSize;
|
||||||
|
const to = from + matchesPageSize - 1;
|
||||||
|
|
||||||
|
const { data: matchesData, error: matchesError, count } = await supabase
|
||||||
.from('matches')
|
.from('matches')
|
||||||
.select(`
|
.select(`
|
||||||
id, couple_id, match_type, is_new, created_at,
|
id, couple_id, match_type, is_new, created_at,
|
||||||
question:questions(id, text)
|
question:questions(id, text)
|
||||||
`)
|
`, { count: 'exact' })
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(50);
|
.range(from, to);
|
||||||
|
|
||||||
if (matchesError) throw matchesError;
|
if (matchesError) throw matchesError;
|
||||||
|
|
||||||
|
setMatchesTotal(count || 0);
|
||||||
|
|
||||||
// Get unique couple IDs and fetch profiles
|
// Get unique couple IDs and fetch profiles
|
||||||
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
|
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
|
||||||
if (coupleIds.length > 0) {
|
if (coupleIds.length > 0) {
|
||||||
@@ -223,14 +246,17 @@ export function UserActivityPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(prev => ({ ...prev, matches: false }));
|
setLoading(prev => ({ ...prev, matches: false }));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [matchesPage, matchesPageSize]);
|
||||||
|
|
||||||
// Fetch messages
|
// Fetch messages
|
||||||
const fetchMessages = useCallback(async () => {
|
const fetchMessages = useCallback(async () => {
|
||||||
setLoading(prev => ({ ...prev, messages: true }));
|
setLoading(prev => ({ ...prev, messages: true }));
|
||||||
try {
|
try {
|
||||||
|
const from = (messagesPage - 1) * messagesPageSize;
|
||||||
|
const to = from + messagesPageSize - 1;
|
||||||
|
|
||||||
// Fetch messages with match data
|
// Fetch messages with match data
|
||||||
const { data: messagesData, error: messagesError } = await supabase
|
const { data: messagesData, error: messagesError, count } = await supabase
|
||||||
.from('messages')
|
.from('messages')
|
||||||
.select(`
|
.select(`
|
||||||
id, user_id, content, media_path, media_type, created_at,
|
id, user_id, content, media_path, media_type, created_at,
|
||||||
@@ -238,12 +264,14 @@ export function UserActivityPage() {
|
|||||||
id,
|
id,
|
||||||
question:questions(text)
|
question:questions(text)
|
||||||
)
|
)
|
||||||
`)
|
`, { count: 'exact' })
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(50);
|
.range(from, to);
|
||||||
|
|
||||||
if (messagesError) throw messagesError;
|
if (messagesError) throw messagesError;
|
||||||
|
|
||||||
|
setMessagesTotal(count || 0);
|
||||||
|
|
||||||
// Get unique user IDs and fetch profiles
|
// Get unique user IDs and fetch profiles
|
||||||
const userIds = [...new Set((messagesData || []).map((m: any) => m.user_id))];
|
const userIds = [...new Set((messagesData || []).map((m: any) => m.user_id))];
|
||||||
let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {};
|
let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {};
|
||||||
@@ -283,27 +311,31 @@ export function UserActivityPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(prev => ({ ...prev, messages: false }));
|
setLoading(prev => ({ ...prev, messages: false }));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [messagesPage, messagesPageSize]);
|
||||||
|
|
||||||
// Fetch signups
|
// Fetch signups
|
||||||
const fetchSignups = useCallback(async () => {
|
const fetchSignups = useCallback(async () => {
|
||||||
setLoading(prev => ({ ...prev, signups: true }));
|
setLoading(prev => ({ ...prev, signups: true }));
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const from = (signupsPage - 1) * signupsPageSize;
|
||||||
|
const to = from + signupsPageSize - 1;
|
||||||
|
|
||||||
|
const { data, error, count } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id')
|
.select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id', { count: 'exact' })
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(50);
|
.range(from, to);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setSignups(data || []);
|
setSignups(data || []);
|
||||||
|
setSignupsTotal(count || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load signups:', error);
|
console.error('Failed to load signups:', error);
|
||||||
toast.error("Failed to load signups");
|
toast.error("Failed to load signups");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(prev => ({ ...prev, signups: false }));
|
setLoading(prev => ({ ...prev, signups: false }));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [signupsPage, signupsPageSize]);
|
||||||
|
|
||||||
// Real-time subscriptions
|
// Real-time subscriptions
|
||||||
const { status: responsesStatus } = useRealtimeSubscription({
|
const { status: responsesStatus } = useRealtimeSubscription({
|
||||||
@@ -338,6 +370,34 @@ export function UserActivityPage() {
|
|||||||
fetchSignups();
|
fetchSignups();
|
||||||
}, [fetchResponses, fetchMatches, fetchMessages, fetchSignups]);
|
}, [fetchResponses, fetchMatches, fetchMessages, fetchSignups]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(responsesTotal / responsesPageSize));
|
||||||
|
if (responsesPage > totalPages) {
|
||||||
|
setResponsesPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [responsesPage, responsesPageSize, responsesTotal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(matchesTotal / matchesPageSize));
|
||||||
|
if (matchesPage > totalPages) {
|
||||||
|
setMatchesPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [matchesPage, matchesPageSize, matchesTotal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(messagesTotal / messagesPageSize));
|
||||||
|
if (messagesPage > totalPages) {
|
||||||
|
setMessagesPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [messagesPage, messagesPageSize, messagesTotal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(signupsTotal / signupsPageSize));
|
||||||
|
if (signupsPage > totalPages) {
|
||||||
|
setSignupsPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [signupsPage, signupsPageSize, signupsTotal]);
|
||||||
|
|
||||||
// Get current realtime status based on active tab
|
// Get current realtime status based on active tab
|
||||||
const getCurrentRealtimeStatus = () => {
|
const getCurrentRealtimeStatus = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
@@ -461,6 +521,19 @@ export function UserActivityPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{responsesTotal > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={responsesPage}
|
||||||
|
pageSize={responsesPageSize}
|
||||||
|
totalCount={responsesTotal}
|
||||||
|
onPageChange={setResponsesPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setResponsesPage(1);
|
||||||
|
setResponsesPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Matches Tab */}
|
{/* Matches Tab */}
|
||||||
@@ -535,6 +608,19 @@ export function UserActivityPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{matchesTotal > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={matchesPage}
|
||||||
|
pageSize={matchesPageSize}
|
||||||
|
totalCount={matchesTotal}
|
||||||
|
onPageChange={setMatchesPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setMatchesPage(1);
|
||||||
|
setMatchesPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Messages Tab */}
|
{/* Messages Tab */}
|
||||||
@@ -593,6 +679,19 @@ export function UserActivityPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{messagesTotal > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={messagesPage}
|
||||||
|
pageSize={messagesPageSize}
|
||||||
|
totalCount={messagesTotal}
|
||||||
|
onPageChange={setMessagesPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setMessagesPage(1);
|
||||||
|
setMessagesPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Signups Tab */}
|
{/* Signups Tab */}
|
||||||
@@ -666,6 +765,19 @@ export function UserActivityPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{signupsTotal > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={signupsPage}
|
||||||
|
pageSize={signupsPageSize}
|
||||||
|
totalCount={signupsTotal}
|
||||||
|
onPageChange={setSignupsPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setSignupsPage(1);
|
||||||
|
setSignupsPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
@@ -58,6 +59,8 @@ export function AdminsPage() {
|
|||||||
const [admins, setAdmins] = useState<AdminUser[]>([]);
|
const [admins, setAdmins] = useState<AdminUser[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
|
||||||
// Add Admin State
|
// Add Admin State
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
@@ -280,6 +283,23 @@ export function AdminsPage() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const totalCount = filteredAdmins.length;
|
||||||
|
const paginatedAdmins = filteredAdmins.slice(
|
||||||
|
(page - 1) * pageSize,
|
||||||
|
page * pageSize
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -509,7 +529,7 @@ export function AdminsPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredAdmins.map((admin) => (
|
paginatedAdmins.map((admin) => (
|
||||||
<TableRow key={admin.user_id}>
|
<TableRow key={admin.user_id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -590,6 +610,19 @@ export function AdminsPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filteredAdmins.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Edit Permissions Dialog */}
|
{/* Edit Permissions Dialog */}
|
||||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { IconPicker, IconPreview } from '@/components/ui/icon-picker';
|
import { IconPicker, IconPreview } from '@/components/ui/icon-picker';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -53,6 +54,9 @@ export function CategoriesPage() {
|
|||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [aiIdeasOpen, setAiIdeasOpen] = useState(false);
|
const [aiIdeasOpen, setAiIdeasOpen] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
// Form state using the new hook
|
// Form state using the new hook
|
||||||
const form = useEntityForm<CategoryFormData, Category>(
|
const form = useEntityForm<CategoryFormData, Category>(
|
||||||
@@ -71,25 +75,34 @@ export function CategoriesPage() {
|
|||||||
const fetchCategories = useCallback(async () => {
|
const fetchCategories = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch categories with pack count
|
const from = (page - 1) * pageSize;
|
||||||
const { data: cats, error } = await supabase
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
|
const { data: cats, error, count } = await supabase
|
||||||
.from('categories')
|
.from('categories')
|
||||||
.select('*')
|
.select('*', { count: 'exact' })
|
||||||
.order('sort_order', { ascending: true });
|
.order('sort_order', { ascending: true })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
// Fetch pack counts per category
|
setTotalCount(count || 0);
|
||||||
const { data: packs } = await supabase
|
|
||||||
.from('question_packs')
|
|
||||||
.select('category_id');
|
|
||||||
|
|
||||||
|
const categoryIds = (cats || []).map(c => c.id);
|
||||||
const packCounts: Record<string, number> = {};
|
const packCounts: Record<string, number> = {};
|
||||||
packs?.forEach(p => {
|
|
||||||
if (p.category_id) {
|
if (categoryIds.length > 0) {
|
||||||
packCounts[p.category_id] = (packCounts[p.category_id] || 0) + 1;
|
const { data: packs } = await supabase
|
||||||
}
|
.from('question_packs')
|
||||||
});
|
.select('category_id')
|
||||||
|
.in('category_id', categoryIds);
|
||||||
|
|
||||||
|
packs?.forEach(p => {
|
||||||
|
if (p.category_id) {
|
||||||
|
packCounts[p.category_id] = (packCounts[p.category_id] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setCategories(
|
setCategories(
|
||||||
(cats || []).map(c => ({
|
(cats || []).map(c => ({
|
||||||
@@ -103,12 +116,19 @@ export function CategoriesPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [page, pageSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
}, [fetchCategories]);
|
}, [fetchCategories]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Handlers
|
// Handlers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -143,7 +163,7 @@ export function CategoriesPage() {
|
|||||||
} else {
|
} else {
|
||||||
const { error } = await auditedSupabase.insert('categories', {
|
const { error } = await auditedSupabase.insert('categories', {
|
||||||
...data,
|
...data,
|
||||||
sort_order: categories.length,
|
sort_order: totalCount,
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
toast.success('Category created');
|
toast.success('Category created');
|
||||||
@@ -406,6 +426,19 @@ export function CategoriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* AI Dialog */}
|
{/* AI Dialog */}
|
||||||
<AiGeneratorDialog
|
<AiGeneratorDialog
|
||||||
open={aiIdeasOpen}
|
open={aiIdeasOpen}
|
||||||
|
|||||||
@@ -0,0 +1,578 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { supabase } from '@/config';
|
||||||
|
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||||
|
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { IconPicker, IconPreview } from '@/components/ui/icon-picker';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, Flame, Heart, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DarePack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
is_premium: boolean;
|
||||||
|
is_public: boolean;
|
||||||
|
is_explicit: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
category_id: string | null;
|
||||||
|
min_intensity: number | null;
|
||||||
|
max_intensity: number | null;
|
||||||
|
avg_intensity: number | null;
|
||||||
|
created_at: string | null;
|
||||||
|
dare_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DarePackFormData {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
is_premium: boolean;
|
||||||
|
is_public: boolean;
|
||||||
|
is_explicit: boolean;
|
||||||
|
category_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function DarePacksPage() {
|
||||||
|
// Data state
|
||||||
|
const [darePacks, setDarePacks] = useState<DarePack[]>([]);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
// Form state using the hook
|
||||||
|
const form = useEntityForm<DarePackFormData, DarePack>(
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
icon: 'flame-outline',
|
||||||
|
is_premium: false,
|
||||||
|
is_public: false,
|
||||||
|
is_explicit: false,
|
||||||
|
category_id: '',
|
||||||
|
},
|
||||||
|
(pack) => ({
|
||||||
|
name: pack.name,
|
||||||
|
description: pack.description || '',
|
||||||
|
icon: pack.icon || 'flame-outline',
|
||||||
|
is_premium: pack.is_premium,
|
||||||
|
is_public: pack.is_public,
|
||||||
|
is_explicit: pack.is_explicit,
|
||||||
|
category_id: pack.category_id || '',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Data Fetching
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
|
// Fetch dare packs
|
||||||
|
const { data: packData, error, count } = await supabase
|
||||||
|
.from('dare_packs')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.order('sort_order', { ascending: true })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
setTotalCount(count || 0);
|
||||||
|
|
||||||
|
// Fetch dare counts
|
||||||
|
const packIds = (packData || []).map(p => p.id);
|
||||||
|
const dareCounts: Record<string, number> = {};
|
||||||
|
|
||||||
|
if (packIds.length > 0) {
|
||||||
|
const { data: dares } = await supabase
|
||||||
|
.from('dares')
|
||||||
|
.select('pack_id')
|
||||||
|
.in('pack_id', packIds);
|
||||||
|
|
||||||
|
dares?.forEach(d => {
|
||||||
|
dareCounts[d.pack_id] = (dareCounts[d.pack_id] || 0) + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch categories
|
||||||
|
const { data: categoryData } = await supabase
|
||||||
|
.from('categories')
|
||||||
|
.select('id, name')
|
||||||
|
.order('name');
|
||||||
|
|
||||||
|
setCategories(categoryData || []);
|
||||||
|
|
||||||
|
setDarePacks(
|
||||||
|
(packData || []).map(p => ({
|
||||||
|
...p,
|
||||||
|
dare_count: dareCounts[p.id] || 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load dare packs');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Handlers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.formData.name.trim()) {
|
||||||
|
toast.error('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setSaving(true);
|
||||||
|
try {
|
||||||
|
if (form.editingItem) {
|
||||||
|
const { error } = await auditedSupabase.update('dare_packs', form.editingItem.id, {
|
||||||
|
name: form.formData.name,
|
||||||
|
description: form.formData.description || null,
|
||||||
|
icon: form.formData.icon || null,
|
||||||
|
is_premium: form.formData.is_premium,
|
||||||
|
is_public: form.formData.is_public,
|
||||||
|
is_explicit: form.formData.is_explicit,
|
||||||
|
category_id: form.formData.category_id || null,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
toast.success('Dare pack updated');
|
||||||
|
} else {
|
||||||
|
const { error } = await auditedSupabase.insert('dare_packs', {
|
||||||
|
name: form.formData.name,
|
||||||
|
description: form.formData.description || null,
|
||||||
|
icon: form.formData.icon || null,
|
||||||
|
is_premium: form.formData.is_premium,
|
||||||
|
is_public: form.formData.is_public,
|
||||||
|
is_explicit: form.formData.is_explicit,
|
||||||
|
category_id: form.formData.category_id || null,
|
||||||
|
sort_order: totalCount,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
toast.success('Dare pack created');
|
||||||
|
}
|
||||||
|
|
||||||
|
form.close();
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to save dare pack');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
form.setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (pack: DarePack) => {
|
||||||
|
if (!confirm(`Delete dare pack "${pack.name}"? This will delete all dares in this pack.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await auditedSupabase.delete('dare_packs', pack.id);
|
||||||
|
if (error) throw error;
|
||||||
|
toast.success('Dare pack deleted');
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to delete dare pack');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMove = async (pack: DarePack, direction: 'up' | 'down') => {
|
||||||
|
const currentIndex = darePacks.findIndex(p => p.id === pack.id);
|
||||||
|
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||||
|
|
||||||
|
if (targetIndex < 0 || targetIndex >= darePacks.length) return;
|
||||||
|
|
||||||
|
const targetPack = darePacks[targetIndex];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentOrder = pack.sort_order ?? currentIndex;
|
||||||
|
const targetOrder = targetPack.sort_order ?? targetIndex;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
auditedSupabase.update('dare_packs', pack.id, { sort_order: targetOrder }),
|
||||||
|
auditedSupabase.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
const newPacks = [...darePacks];
|
||||||
|
[newPacks[currentIndex], newPacks[targetIndex]] = [newPacks[targetIndex], newPacks[currentIndex]];
|
||||||
|
setDarePacks(newPacks);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to reorder dare packs');
|
||||||
|
console.error(error);
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIntensityColor = (avg: number | null) => {
|
||||||
|
if (avg === null) return 'bg-muted';
|
||||||
|
if (avg <= 1.5) return 'bg-green-500';
|
||||||
|
if (avg <= 2.5) return 'bg-lime-500';
|
||||||
|
if (avg <= 3.5) return 'bg-yellow-500';
|
||||||
|
if (avg <= 4.5) return 'bg-orange-500';
|
||||||
|
return 'bg-red-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Render
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton className="h-9 w-48" />
|
||||||
|
<Skeleton className="h-10 w-40" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<Skeleton key={i} className="h-48" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
<Zap className="h-8 w-8 text-orange-500" />
|
||||||
|
Dare Packs
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage dare packs that users can send to their partners
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={form.dialogOpen} onOpenChange={form.setDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={form.openCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Dare Pack
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{form.isEditing ? 'Edit Dare Pack' : 'Create Dare Pack'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{form.isEditing
|
||||||
|
? 'Update the dare pack details below.'
|
||||||
|
: 'Add a new dare pack for couples to challenge each other.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Icon</Label>
|
||||||
|
<IconPicker
|
||||||
|
value={form.formData.icon}
|
||||||
|
onChange={(iconName) => form.setField('icon', iconName)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={form.formData.name}
|
||||||
|
onChange={(e) => form.setField('name', e.target.value)}
|
||||||
|
placeholder="e.g., Romantic Dares"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={form.formData.description}
|
||||||
|
onChange={(e) => form.setField('description', e.target.value)}
|
||||||
|
placeholder="Describe this dare pack..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Category (optional)</Label>
|
||||||
|
<Select
|
||||||
|
value={form.formData.category_id || '__none__'}
|
||||||
|
onValueChange={(value) => form.setField('category_id', value === '__none__' ? '' : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">No category</SelectItem>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<SelectItem key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Premium</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Only available to premium users
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={form.formData.is_premium}
|
||||||
|
onCheckedChange={(checked) => form.setField('is_premium', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Published</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Visible to users in the app
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={form.formData.is_public}
|
||||||
|
onCheckedChange={(checked) => form.setField('is_public', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Explicit</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Contains adult/explicit content
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={form.formData.is_explicit}
|
||||||
|
onCheckedChange={(checked) => form.setField('is_explicit', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={form.close}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={form.saving}>
|
||||||
|
{form.saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dare Packs Grid */}
|
||||||
|
{darePacks.length === 0 ? (
|
||||||
|
<Card className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold">No dare packs yet</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Create your first dare pack to get started
|
||||||
|
</p>
|
||||||
|
<Button onClick={form.openCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Dare Pack
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{darePacks.map((pack, index) => (
|
||||||
|
<Card key={pack.id} className="group hover:shadow-md transition-shadow">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex flex-col opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => handleMove(pack, 'up')}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => handleMove(pack, 'down')}
|
||||||
|
disabled={index === darePacks.length - 1}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-4xl mb-2">
|
||||||
|
<IconPreview value={pack.icon} fallback="flame-outline" className="text-4xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{pack.is_premium && (
|
||||||
|
<Badge variant="default" className="bg-amber-500">
|
||||||
|
<Crown className="h-3 w-3 mr-1" />
|
||||||
|
Premium
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{pack.is_public ? (
|
||||||
|
<Badge variant="outline" className="border-green-500 text-green-400">
|
||||||
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
|
Published
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<EyeOff className="h-3 w-3 mr-1" />
|
||||||
|
Draft
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{pack.is_explicit ? (
|
||||||
|
<Badge variant="outline" className="border-red-500 text-red-400">
|
||||||
|
<Flame className="h-3 w-3 mr-1" />
|
||||||
|
Explicit
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="border-pink-400 text-pink-300">
|
||||||
|
<Heart className="h-3 w-3 mr-1" />
|
||||||
|
Safe
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<CardTitle className="flex-1">{pack.name}</CardTitle>
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => form.openEdit(pack)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(pack)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="line-clamp-2">
|
||||||
|
{pack.description || 'No description'}
|
||||||
|
</CardDescription>
|
||||||
|
{/* Intensity indicator */}
|
||||||
|
{pack.avg_intensity !== null && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Badge className={`${getIntensityColor(pack.avg_intensity)} text-white`}>
|
||||||
|
Avg: {pack.avg_intensity?.toFixed(1)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Range: {pack.min_intensity ?? '?'} - {pack.max_intensity ?? '?'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{pack.dare_count} dare{pack.dare_count !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<Link to={`/dare-packs/${pack.id}/dares`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Manage Dares
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{darePacks.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { supabase } from '@/config';
|
||||||
|
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Plus, Pencil, Trash2, Zap, Loader2, Clock } from 'lucide-react';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { IconPreview } from '@/components/ui/icon-picker';
|
||||||
|
import { INTENSITY_LEVELS } from '@/lib/openai';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface DarePack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
is_explicit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Dare {
|
||||||
|
id: string;
|
||||||
|
pack_id: string;
|
||||||
|
text: string;
|
||||||
|
intensity: number;
|
||||||
|
suggested_duration_hours: number | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration options for dares
|
||||||
|
const DURATION_OPTIONS = [
|
||||||
|
{ value: '', label: 'No suggestion' },
|
||||||
|
{ value: '1', label: '1 hour' },
|
||||||
|
{ value: '6', label: '6 hours' },
|
||||||
|
{ value: '12', label: '12 hours' },
|
||||||
|
{ value: '24', label: '24 hours' },
|
||||||
|
{ value: '72', label: '3 days' },
|
||||||
|
{ value: '168', label: '1 week' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper to get intensity info by level (1-indexed)
|
||||||
|
const getIntensity = (level: number) => INTENSITY_LEVELS[level - 1] || INTENSITY_LEVELS[0];
|
||||||
|
|
||||||
|
// Format duration for display
|
||||||
|
const formatDuration = (hours: number | null): string => {
|
||||||
|
if (hours === null) return '—';
|
||||||
|
if (hours < 24) return `${hours}h`;
|
||||||
|
if (hours < 168) return `${Math.round(hours / 24)}d`;
|
||||||
|
return `${Math.round(hours / 168)}w`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function DaresPage() {
|
||||||
|
const { packId } = useParams<{ packId: string }>();
|
||||||
|
const [pack, setPack] = useState<DarePack | null>(null);
|
||||||
|
const [dares, setDares] = useState<Dare[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingDare, setEditingDare] = useState<Dare | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [formData, setFormData] = useState<{
|
||||||
|
text: string;
|
||||||
|
intensity: number;
|
||||||
|
suggested_duration_hours: string;
|
||||||
|
}>({
|
||||||
|
text: '',
|
||||||
|
intensity: 1,
|
||||||
|
suggested_duration_hours: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (!packId) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Fetch pack info
|
||||||
|
const { data: packData } = await supabase
|
||||||
|
.from('dare_packs')
|
||||||
|
.select('id, name, description, icon, is_explicit')
|
||||||
|
.eq('id', packId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
setPack(packData);
|
||||||
|
|
||||||
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
|
// Fetch dares
|
||||||
|
const { data: dareData, error, count } = await supabase
|
||||||
|
.from('dares')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.eq('pack_id', packId)
|
||||||
|
.order('created_at', { ascending: true })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setTotalCount(count || 0);
|
||||||
|
setDares(dareData || []);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load dares');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [packId, page, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}, [packId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}, [page, pageSize]);
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
setEditingDare(null);
|
||||||
|
setFormData({ text: '', intensity: 1, suggested_duration_hours: '' });
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (dare: Dare) => {
|
||||||
|
setEditingDare(dare);
|
||||||
|
setFormData({
|
||||||
|
text: dare.text,
|
||||||
|
intensity: dare.intensity,
|
||||||
|
suggested_duration_hours: dare.suggested_duration_hours?.toString() || '',
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.text.trim()) {
|
||||||
|
toast.error('Dare text is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const dareData = {
|
||||||
|
text: formData.text,
|
||||||
|
intensity: formData.intensity,
|
||||||
|
suggested_duration_hours: formData.suggested_duration_hours
|
||||||
|
? parseInt(formData.suggested_duration_hours, 10)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingDare) {
|
||||||
|
const { error } = await auditedSupabase.update('dares', editingDare.id, dareData);
|
||||||
|
if (error) throw error;
|
||||||
|
toast.success('Dare updated');
|
||||||
|
} else {
|
||||||
|
const { error } = await auditedSupabase.insert('dares', {
|
||||||
|
...dareData,
|
||||||
|
pack_id: packId,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
toast.success('Dare created');
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogOpen(false);
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to save dare');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (dare: Dare) => {
|
||||||
|
if (!confirm('Delete this dare? This cannot be undone.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await auditedSupabase.delete('dares', dare.id);
|
||||||
|
if (error) throw error;
|
||||||
|
toast.success('Dare deleted');
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to delete dare');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Selection handlers
|
||||||
|
const toggleSelection = (id: string) => {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
setSelectedIds(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (selectedIds.size === dares.length) {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set(dares.map(d => d.id)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkDelete = async () => {
|
||||||
|
if (selectedIds.size === 0) return;
|
||||||
|
|
||||||
|
const count = selectedIds.size;
|
||||||
|
if (!confirm(`Delete ${count} dare${count !== 1 ? 's' : ''}? This cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBulkDeleting(true);
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(selectedIds).map(id => auditedSupabase.delete('dares', id))
|
||||||
|
);
|
||||||
|
toast.success(`${count} dare${count !== 1 ? 's' : ''} deleted`);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to delete dares');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setBulkDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton className="h-9 w-64" />
|
||||||
|
<Skeleton className="h-10 w-40" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-96" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
{pack && (
|
||||||
|
<>
|
||||||
|
<span className="text-3xl">
|
||||||
|
<IconPreview value={pack.icon} fallback="flame-outline" className="text-3xl" />
|
||||||
|
</span>
|
||||||
|
{pack.name}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{totalCount} dare{totalCount !== 1 ? 's' : ''} in this pack
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={openCreateDialog}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Dare
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingDare ? 'Edit Dare' : 'Add Dare'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editingDare
|
||||||
|
? 'Update the dare details below.'
|
||||||
|
: 'Add a new dare to this pack.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="text">Dare Text</Label>
|
||||||
|
<Textarea
|
||||||
|
id="text"
|
||||||
|
value={formData.text}
|
||||||
|
onChange={(e) => setFormData(d => ({ ...d, text: e.target.value }))}
|
||||||
|
placeholder="e.g., Give your partner a 5-minute massage..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Intensity Level</Label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{INTENSITY_LEVELS.map((intensity) => (
|
||||||
|
<Button
|
||||||
|
key={intensity.level}
|
||||||
|
type="button"
|
||||||
|
variant={formData.intensity === intensity.level ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFormData(d => ({ ...d, intensity: intensity.level }))}
|
||||||
|
className={formData.intensity === intensity.level ? intensity.color : ''}
|
||||||
|
title={intensity.description}
|
||||||
|
>
|
||||||
|
{intensity.level} - {intensity.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Suggested Duration</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.suggested_duration_hours}
|
||||||
|
onValueChange={(value) => setFormData(d => ({ ...d, suggested_duration_hours: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a duration" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DURATION_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This is a suggestion shown to users when sending the dare. They can choose a different timeframe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk Action Bar */}
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<div className="flex items-center justify-between bg-muted/50 border rounded-lg px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{selectedIds.size} dare{selectedIds.size !== 1 ? 's' : ''} selected
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedIds(new Set())}
|
||||||
|
>
|
||||||
|
Clear selection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBulkDelete}
|
||||||
|
disabled={bulkDeleting}
|
||||||
|
>
|
||||||
|
{bulkDeleting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete {selectedIds.size} Dare{selectedIds.size !== 1 ? 's' : ''}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dares Table */}
|
||||||
|
{dares.length === 0 ? (
|
||||||
|
<Card className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold">No dares yet</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Add dares to this pack for couples to challenge each other
|
||||||
|
</p>
|
||||||
|
<Button onClick={openCreateDialog}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Dare
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-12">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.size === dares.length && dares.length > 0}
|
||||||
|
onCheckedChange={toggleSelectAll}
|
||||||
|
aria-label="Select all"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-12">#</TableHead>
|
||||||
|
<TableHead>Dare</TableHead>
|
||||||
|
<TableHead className="w-24">Intensity</TableHead>
|
||||||
|
<TableHead className="w-24">Duration</TableHead>
|
||||||
|
<TableHead className="w-24">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{dares.map((dare, index) => (
|
||||||
|
<TableRow
|
||||||
|
key={dare.id}
|
||||||
|
className={selectedIds.has(dare.id) ? 'bg-muted/50' : ''}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(dare.id)}
|
||||||
|
onCheckedChange={() => toggleSelection(dare.id)}
|
||||||
|
aria-label={`Select dare ${index + 1}`}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium text-muted-foreground">
|
||||||
|
{(page - 1) * pageSize + index + 1}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="line-clamp-2">{dare.text}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
className={`${getIntensity(dare.intensity).color} text-white`}
|
||||||
|
title={getIntensity(dare.intensity).description}
|
||||||
|
>
|
||||||
|
{getIntensity(dare.intensity).label}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{dare.suggested_duration_hours ? (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Clock className="h-3 w-3 mr-1" />
|
||||||
|
{formatDuration(dare.suggested_duration_hours)}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openEditDialog(dare)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(dare)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dares.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown } from 'lucide-react';
|
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||||
@@ -60,6 +61,9 @@ export function PacksPage() {
|
|||||||
const [extractTopicsPack, setExtractTopicsPack] = useState<QuestionPack | null>(null);
|
const [extractTopicsPack, setExtractTopicsPack] = useState<QuestionPack | null>(null);
|
||||||
const [allTopics, setAllTopics] = useState<Topic[]>([]);
|
const [allTopics, setAllTopics] = useState<Topic[]>([]);
|
||||||
const [selectedTopicIds, setSelectedTopicIds] = useState<Set<string>>(new Set());
|
const [selectedTopicIds, setSelectedTopicIds] = useState<Set<string>>(new Set());
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
// Form state using the hook
|
// Form state using the hook
|
||||||
const form = useEntityForm<PackFormData, QuestionPack>(
|
const form = useEntityForm<PackFormData, QuestionPack>(
|
||||||
@@ -98,30 +102,39 @@ export function PacksPage() {
|
|||||||
setCategory(cat);
|
setCategory(cat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
// Fetch packs
|
// Fetch packs
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from('question_packs')
|
.from('question_packs')
|
||||||
.select('*')
|
.select('*', { count: 'exact' })
|
||||||
.order('sort_order', { ascending: true });
|
.order('sort_order', { ascending: true })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
query = query.eq('category_id', categoryId);
|
query = query.eq('category_id', categoryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: packData, error } = await query;
|
const { data: packData, error, count } = await query;
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
setTotalCount(count || 0);
|
||||||
|
|
||||||
// Fetch question counts
|
// Fetch question counts
|
||||||
const packIds = (packData || []).map(p => p.id);
|
const packIds = (packData || []).map(p => p.id);
|
||||||
const { data: questions } = await supabase
|
|
||||||
.from('questions')
|
|
||||||
.select('pack_id')
|
|
||||||
.in('pack_id', packIds);
|
|
||||||
|
|
||||||
const questionCounts: Record<string, number> = {};
|
const questionCounts: Record<string, number> = {};
|
||||||
questions?.forEach(q => {
|
|
||||||
questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1;
|
if (packIds.length > 0) {
|
||||||
});
|
const { data: questions } = await supabase
|
||||||
|
.from('questions')
|
||||||
|
.select('pack_id')
|
||||||
|
.in('pack_id', packIds);
|
||||||
|
|
||||||
|
questions?.forEach(q => {
|
||||||
|
questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch all topics
|
// Fetch all topics
|
||||||
const { data: topicsData } = await supabase
|
const { data: topicsData } = await supabase
|
||||||
@@ -131,13 +144,17 @@ export function PacksPage() {
|
|||||||
setAllTopics(topicsData || []);
|
setAllTopics(topicsData || []);
|
||||||
|
|
||||||
// Fetch pack_topics relationships
|
// Fetch pack_topics relationships
|
||||||
const { data: packTopicsData } = await supabase
|
let packTopicsData: any[] = [];
|
||||||
.from('pack_topics')
|
if (packIds.length > 0) {
|
||||||
.select('pack_id, topic_id, topics(id, name)')
|
const { data } = await supabase
|
||||||
.in('pack_id', packIds);
|
.from('pack_topics')
|
||||||
|
.select('pack_id, topic_id, topics(id, name)')
|
||||||
|
.in('pack_id', packIds);
|
||||||
|
packTopicsData = data || [];
|
||||||
|
}
|
||||||
|
|
||||||
const packTopicsMap: Record<string, Topic[]> = {};
|
const packTopicsMap: Record<string, Topic[]> = {};
|
||||||
packTopicsData?.forEach((pt: any) => {
|
packTopicsData.forEach((pt: any) => {
|
||||||
if (!packTopicsMap[pt.pack_id]) {
|
if (!packTopicsMap[pt.pack_id]) {
|
||||||
packTopicsMap[pt.pack_id] = [];
|
packTopicsMap[pt.pack_id] = [];
|
||||||
}
|
}
|
||||||
@@ -159,12 +176,19 @@ export function PacksPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [categoryId]);
|
}, [categoryId, page, pageSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Handlers
|
// Handlers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -211,7 +235,7 @@ export function PacksPage() {
|
|||||||
is_public: form.formData.is_public,
|
is_public: form.formData.is_public,
|
||||||
is_explicit: form.formData.is_explicit,
|
is_explicit: form.formData.is_explicit,
|
||||||
category_id: categoryId || null,
|
category_id: categoryId || null,
|
||||||
sort_order: packs.length,
|
sort_order: totalCount,
|
||||||
})
|
})
|
||||||
.select('id')
|
.select('id')
|
||||||
.single();
|
.single();
|
||||||
@@ -535,6 +559,19 @@ export function PacksPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{packs.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -70,6 +71,9 @@ export function QuestionsPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState<{
|
const [formData, setFormData] = useState<{
|
||||||
@@ -100,14 +104,19 @@ export function QuestionsPage() {
|
|||||||
|
|
||||||
setPack(packData);
|
setPack(packData);
|
||||||
|
|
||||||
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
// Fetch questions
|
// Fetch questions
|
||||||
const { data: questionData, error } = await supabase
|
const { data: questionData, error, count } = await supabase
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('*')
|
.select('*', { count: 'exact' })
|
||||||
.eq('pack_id', packId)
|
.eq('pack_id', packId)
|
||||||
.order('created_at', { ascending: true });
|
.order('created_at', { ascending: true })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
setTotalCount(count || 0);
|
||||||
setQuestions(questionData || []);
|
setQuestions(questionData || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to load questions');
|
toast.error('Failed to load questions');
|
||||||
@@ -115,33 +124,49 @@ export function QuestionsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [packId]);
|
}, [packId, page, pageSize]);
|
||||||
|
|
||||||
// Real-time subscription for questions in this pack
|
// Real-time subscription for questions in this pack
|
||||||
const { status: realtimeStatus } = useRealtimeSubscription<Question>({
|
const { status: realtimeStatus } = useRealtimeSubscription<Question>({
|
||||||
table: 'questions',
|
table: 'questions',
|
||||||
filter: packId ? `pack_id=eq.${packId}` : undefined,
|
filter: packId ? `pack_id=eq.${packId}` : undefined,
|
||||||
enabled: !!packId,
|
enabled: !!packId,
|
||||||
onInsert: useCallback((newQuestion: Question) => {
|
onInsert: useCallback(() => {
|
||||||
setQuestions(prev => [...prev, newQuestion]);
|
fetchData();
|
||||||
}, []),
|
}, [fetchData]),
|
||||||
onUpdate: useCallback(({ new: updated }: { old: Question; new: Question }) => {
|
onUpdate: useCallback(() => {
|
||||||
setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q));
|
fetchData();
|
||||||
}, []),
|
}, [fetchData]),
|
||||||
onDelete: useCallback((deleted: Question) => {
|
onDelete: useCallback((deleted: Question) => {
|
||||||
setQuestions(prev => prev.filter(q => q.id !== deleted.id));
|
|
||||||
setSelectedIds(prev => {
|
setSelectedIds(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(deleted.id);
|
next.delete(deleted.id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []),
|
fetchData();
|
||||||
|
}, [fetchData]),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}, [packId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}, [page, pageSize]);
|
||||||
|
|
||||||
const openCreateDialog = () => {
|
const openCreateDialog = () => {
|
||||||
setEditingQuestion(null);
|
setEditingQuestion(null);
|
||||||
setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] });
|
setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] });
|
||||||
@@ -319,7 +344,7 @@ export function QuestionsPage() {
|
|||||||
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{questions.length} question{questions.length !== 1 ? 's' : ''} in this pack
|
{totalCount} question{totalCount !== 1 ? 's' : ''} in this pack
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -615,7 +640,7 @@ export function QuestionsPage() {
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium text-muted-foreground">
|
<TableCell className="font-medium text-muted-foreground">
|
||||||
{index + 1}
|
{(page - 1) * pageSize + index + 1}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="line-clamp-2">{question.text}</span>
|
<span className="line-clamp-2">{question.text}</span>
|
||||||
@@ -689,6 +714,19 @@ export function QuestionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{questions.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div >
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import { format, isToday, isYesterday } from 'date-fns';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
|
|
||||||
interface Profile {
|
interface Profile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -331,79 +332,103 @@ export function MatchChatPage() {
|
|||||||
const [match, setMatch] = useState<Match | null>(null);
|
const [match, setMatch] = useState<Match | null>(null);
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(50);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (!userId || !matchId) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Fetch user profile
|
||||||
|
const { data: userData } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('id, name, avatar_url, couple_id')
|
||||||
|
.eq('id', userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
setUser(userData);
|
||||||
|
|
||||||
|
// Fetch partner
|
||||||
|
if (userData?.couple_id) {
|
||||||
|
const { data: partnerData } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('id, name, avatar_url')
|
||||||
|
.eq('couple_id', userData.couple_id)
|
||||||
|
.neq('id', userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
setPartner(partnerData);
|
||||||
|
} else {
|
||||||
|
setPartner(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch match with question
|
||||||
|
const { data: matchData } = await supabase
|
||||||
|
.from('matches')
|
||||||
|
.select(`
|
||||||
|
id, match_type,
|
||||||
|
question:questions (id, text)
|
||||||
|
`)
|
||||||
|
.eq('id', matchId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
setMatch(matchData as unknown as Match);
|
||||||
|
|
||||||
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
|
// Fetch messages
|
||||||
|
const { data: messageData, error: messagesError, count } = await supabase
|
||||||
|
.from('messages')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.eq('match_id', matchId)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
|
if (messagesError) throw messagesError;
|
||||||
|
|
||||||
|
setTotalCount(count || 0);
|
||||||
|
const orderedMessages = (messageData || []).slice().reverse();
|
||||||
|
setMessages(orderedMessages);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load chat data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [userId, matchId, page, pageSize]);
|
||||||
|
|
||||||
// Real-time subscription for messages in this chat
|
// Real-time subscription for messages in this chat
|
||||||
const { status: realtimeStatus } = useRealtimeSubscription<Message>({
|
const { status: realtimeStatus } = useRealtimeSubscription<Message>({
|
||||||
table: 'messages',
|
table: 'messages',
|
||||||
filter: matchId ? `match_id=eq.${matchId}` : undefined,
|
filter: matchId ? `match_id=eq.${matchId}` : undefined,
|
||||||
enabled: !!matchId,
|
enabled: !!matchId,
|
||||||
onInsert: useCallback((newMessage: Message) => {
|
onInsert: useCallback(() => {
|
||||||
setMessages(prev => [...prev, newMessage]);
|
fetchData();
|
||||||
}, []),
|
}, [fetchData]),
|
||||||
onUpdate: useCallback(({ new: updated }: { old: Message; new: Message }) => {
|
onUpdate: useCallback(() => {
|
||||||
setMessages(prev => prev.map(m => m.id === updated.id ? updated : m));
|
fetchData();
|
||||||
}, []),
|
}, [fetchData]),
|
||||||
onDelete: useCallback((deleted: Message) => {
|
onDelete: useCallback(() => {
|
||||||
setMessages(prev => prev.filter(m => m.id !== deleted.id));
|
fetchData();
|
||||||
}, []),
|
}, [fetchData]),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
|
||||||
if (!userId || !matchId) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
// Fetch user profile
|
|
||||||
const { data: userData } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.select('id, name, avatar_url, couple_id')
|
|
||||||
.eq('id', userId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
setUser(userData);
|
|
||||||
|
|
||||||
// Fetch partner
|
|
||||||
if (userData?.couple_id) {
|
|
||||||
const { data: partnerData } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.select('id, name, avatar_url')
|
|
||||||
.eq('couple_id', userData.couple_id)
|
|
||||||
.neq('id', userId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
setPartner(partnerData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch match with question
|
|
||||||
const { data: matchData } = await supabase
|
|
||||||
.from('matches')
|
|
||||||
.select(`
|
|
||||||
id, match_type,
|
|
||||||
question:questions (id, text)
|
|
||||||
`)
|
|
||||||
.eq('id', matchId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
setMatch(matchData as unknown as Match);
|
|
||||||
|
|
||||||
// Fetch messages
|
|
||||||
const { data: messageData } = await supabase
|
|
||||||
.from('messages')
|
|
||||||
.select('*')
|
|
||||||
.eq('match_id', matchId)
|
|
||||||
.order('created_at', { ascending: true });
|
|
||||||
|
|
||||||
setMessages(messageData || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load chat data:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [userId, matchId]);
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [matchId]);
|
||||||
|
|
||||||
const getProfile = (senderId: string): Profile | null => {
|
const getProfile = (senderId: string): Profile | null => {
|
||||||
if (senderId === user?.id) return user;
|
if (senderId === user?.id) return user;
|
||||||
@@ -497,7 +522,7 @@ export function MatchChatPage() {
|
|||||||
<Card className="min-h-[400px]">
|
<Card className="min-h-[400px]">
|
||||||
<CardHeader className="py-4 border-b">
|
<CardHeader className="py-4 border-b">
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle className="text-sm font-medium">
|
||||||
Messages ({messages.length})
|
Messages ({totalCount})
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
@@ -580,6 +605,21 @@ export function MatchChatPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
@@ -237,6 +238,15 @@ export function UserDetailPage() {
|
|||||||
const [responses, setResponses] = useState<Response[]>([]);
|
const [responses, setResponses] = useState<Response[]>([]);
|
||||||
const [matches, setMatches] = useState<Match[]>([]);
|
const [matches, setMatches] = useState<Match[]>([]);
|
||||||
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
|
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
|
||||||
|
const [responsesPage, setResponsesPage] = useState(1);
|
||||||
|
const [responsesPageSize, setResponsesPageSize] = useState(10);
|
||||||
|
const [responsesTotal, setResponsesTotal] = useState(0);
|
||||||
|
const [matchesPage, setMatchesPage] = useState(1);
|
||||||
|
const [matchesPageSize, setMatchesPageSize] = useState(10);
|
||||||
|
const [matchesTotal, setMatchesTotal] = useState(0);
|
||||||
|
const [mediaPage, setMediaPage] = useState(1);
|
||||||
|
const [mediaPageSize, setMediaPageSize] = useState(12);
|
||||||
|
const [mediaTotal, setMediaTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
|
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
|
||||||
@@ -308,44 +318,121 @@ export function UserDetailPage() {
|
|||||||
setPartner(null);
|
setPartner(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: responseData } = await supabase.from('responses').select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))').eq('user_id', userId).order('created_at', { ascending: false }).limit(50);
|
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
||||||
|
const responsesTo = responsesFrom + responsesPageSize - 1;
|
||||||
|
|
||||||
|
const { data: responseData, count: responsesCount, error: responsesError } = await supabase
|
||||||
|
.from('responses')
|
||||||
|
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))', { count: 'exact' })
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.range(responsesFrom, responsesTo);
|
||||||
|
|
||||||
|
if (responsesError) throw responsesError;
|
||||||
|
|
||||||
setResponses((responseData || []) as unknown as Response[]);
|
setResponses((responseData || []) as unknown as Response[]);
|
||||||
|
setResponsesTotal(responsesCount || 0);
|
||||||
|
|
||||||
if (profileData?.couple_id) {
|
if (profileData?.couple_id) {
|
||||||
const { data: matchData } = await supabase.from('matches').select('id, match_type, is_new, created_at, question:questions(id, text)').eq('couple_id', profileData.couple_id).order('created_at', { ascending: false }).limit(50);
|
const matchesFrom = (matchesPage - 1) * matchesPageSize;
|
||||||
|
const matchesTo = matchesFrom + matchesPageSize - 1;
|
||||||
|
|
||||||
|
const { data: matchData, count: matchesCount, error: matchesError } = await supabase
|
||||||
|
.from('matches')
|
||||||
|
.select('id, match_type, is_new, created_at, question:questions(id, text)', { count: 'exact' })
|
||||||
|
.eq('couple_id', profileData.couple_id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.range(matchesFrom, matchesTo);
|
||||||
|
|
||||||
|
if (matchesError) throw matchesError;
|
||||||
|
|
||||||
|
setMatchesTotal(matchesCount || 0);
|
||||||
|
|
||||||
const matchIds = (matchData || []).map(m => m.id);
|
const matchIds = (matchData || []).map(m => m.id);
|
||||||
if (matchIds.length > 0) {
|
if (matchIds.length > 0) {
|
||||||
const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds);
|
const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds);
|
||||||
const messageCounts: Record<string, number> = {};
|
const messageCounts: Record<string, number> = {};
|
||||||
messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; });
|
messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; });
|
||||||
setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]);
|
setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]);
|
||||||
const { data: mediaMessagesData, error: mediaError } = await supabase.from('messages').select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id').in('match_id', matchIds).not('media_path', 'is', null).order('created_at', { ascending: false });
|
} else {
|
||||||
|
setMatches([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: allMatchIdsData } = await supabase
|
||||||
|
.from('matches')
|
||||||
|
.select('id')
|
||||||
|
.eq('couple_id', profileData.couple_id);
|
||||||
|
|
||||||
|
const allMatchIds = (allMatchIdsData || []).map(m => m.id);
|
||||||
|
if (allMatchIds.length > 0) {
|
||||||
|
const mediaFrom = (mediaPage - 1) * mediaPageSize;
|
||||||
|
const mediaTo = mediaFrom + mediaPageSize - 1;
|
||||||
|
|
||||||
|
const { data: mediaMessagesData, error: mediaError, count: mediaCount } = await supabase
|
||||||
|
.from('messages')
|
||||||
|
.select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id', { count: 'exact' })
|
||||||
|
.in('match_id', allMatchIds)
|
||||||
|
.not('media_path', 'is', null)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.range(mediaFrom, mediaTo);
|
||||||
|
|
||||||
if (mediaError) {
|
if (mediaError) {
|
||||||
console.error("Error fetching media messages:", mediaError);
|
console.error("Error fetching media messages:", mediaError);
|
||||||
setMediaMessages([]);
|
setMediaMessages([]);
|
||||||
|
setMediaTotal(0);
|
||||||
} else {
|
} else {
|
||||||
setMediaMessages(mediaMessagesData || []);
|
setMediaMessages(mediaMessagesData || []);
|
||||||
|
setMediaTotal(mediaCount || 0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setMatches([]);
|
|
||||||
setMediaMessages([]);
|
setMediaMessages([]);
|
||||||
|
setMediaTotal(0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setMatches([]);
|
setMatches([]);
|
||||||
|
setMatchesTotal(0);
|
||||||
setMediaMessages([]);
|
setMediaMessages([]);
|
||||||
|
setMediaTotal(0);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load user data:', error);
|
console.error('Failed to load user data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [userId]);
|
}, [userId, responsesPage, responsesPageSize, matchesPage, matchesPageSize, mediaPage, mediaPageSize]);
|
||||||
|
|
||||||
const { status: profileStatus } = useRealtimeSubscription<Profile>({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) });
|
const { status: profileStatus } = useRealtimeSubscription<Profile>({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) });
|
||||||
const { status: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData });
|
const { status: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData });
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setResponsesPage(1);
|
||||||
|
setMatchesPage(1);
|
||||||
|
setMediaPage(1);
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(responsesTotal / responsesPageSize));
|
||||||
|
if (responsesPage > totalPages) {
|
||||||
|
setResponsesPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [responsesPage, responsesPageSize, responsesTotal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(matchesTotal / matchesPageSize));
|
||||||
|
if (matchesPage > totalPages) {
|
||||||
|
setMatchesPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [matchesPage, matchesPageSize, matchesTotal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(mediaTotal / mediaPageSize));
|
||||||
|
if (mediaPage > totalPages) {
|
||||||
|
setMediaPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [mediaPage, mediaPageSize, mediaTotal]);
|
||||||
|
|
||||||
if (loading) return <div className="space-y-6"><Skeleton className="h-32" /><Skeleton className="h-96" /></div>;
|
if (loading) return <div className="space-y-6"><Skeleton className="h-32" /><Skeleton className="h-96" /></div>;
|
||||||
if (!profile) return <div className="flex flex-col items-center justify-center py-12"><p className="text-lg font-medium">User not found</p><Link to="/users"><Button variant="link">Back to users</Button></Link></div>;
|
if (!profile) return <div className="flex flex-col items-center justify-center py-12"><p className="text-lg font-medium">User not found</p><Link to="/users"><Button variant="link">Back to users</Button></Link></div>;
|
||||||
|
|
||||||
@@ -378,19 +465,19 @@ export function UserDetailPage() {
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
{canViewResponses && <TabsTrigger value="responses">Responses ({responses.length})</TabsTrigger>}
|
{canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
|
||||||
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matches.length})</TabsTrigger>}
|
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
|
||||||
{canViewMedia && <TabsTrigger value="media">Media ({mediaMessages.length})</TabsTrigger>}
|
{canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div>}</TabsContent>}
|
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
|
||||||
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div>}</TabsContent>}
|
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
|
||||||
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
|
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
|
||||||
{message.media_type === 'video' ? (
|
{message.media_type === 'video' ? (
|
||||||
<AdminDecryptedVideo messageId={message.id} />
|
<AdminDecryptedVideo messageId={message.id} />
|
||||||
) : (
|
) : (
|
||||||
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
|
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
|
||||||
)}
|
)}
|
||||||
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div></div>}</TabsContent>}
|
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||||
@@ -46,16 +47,20 @@ export function UsersPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filteredProfiles, setFilteredProfiles] = useState<Profile[]>([]);
|
const [filteredProfiles, setFilteredProfiles] = useState<Profile[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
|
||||||
const fetchProfiles = useCallback(async () => {
|
const fetchProfiles = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch profiles with auth info (includes last_sign_in_at)
|
// Fetch profiles with auth info (includes last_sign_in_at)
|
||||||
const { data: profilesData, error } = await supabase
|
const { data: profilesDataRaw, error } = await supabase
|
||||||
.rpc('get_profiles_with_auth_info');
|
.rpc('get_profiles_with_auth_info');
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
const profilesData = (profilesDataRaw || []) as Profile[];
|
||||||
|
|
||||||
// Fetch storage usage per user from storage.objects
|
// Fetch storage usage per user from storage.objects
|
||||||
const { data: storageData } = await supabase
|
const { data: storageData } = await supabase
|
||||||
.rpc('get_user_storage_usage');
|
.rpc('get_user_storage_usage');
|
||||||
@@ -70,7 +75,7 @@ export function UsersPage() {
|
|||||||
|
|
||||||
// Build a map of couple_id -> users with premium in that couple
|
// Build a map of couple_id -> users with premium in that couple
|
||||||
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
|
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
|
||||||
(profilesData || []).forEach(profile => {
|
profilesData.forEach(profile => {
|
||||||
if (profile.couple_id) {
|
if (profile.couple_id) {
|
||||||
if (!couplePartners[profile.couple_id]) {
|
if (!couplePartners[profile.couple_id]) {
|
||||||
couplePartners[profile.couple_id] = [];
|
couplePartners[profile.couple_id] = [];
|
||||||
@@ -83,7 +88,7 @@ export function UsersPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Merge storage data and partner premium status with profiles
|
// Merge storage data and partner premium status with profiles
|
||||||
const profilesWithExtras = (profilesData || []).map(profile => {
|
const profilesWithExtras = profilesData.map(profile => {
|
||||||
// Check if partner has premium
|
// Check if partner has premium
|
||||||
let partnerIsPremium = false;
|
let partnerIsPremium = false;
|
||||||
if (profile.couple_id && couplePartners[profile.couple_id]) {
|
if (profile.couple_id && couplePartners[profile.couple_id]) {
|
||||||
@@ -134,6 +139,7 @@ export function UsersPage() {
|
|||||||
}, [fetchProfiles]);
|
}, [fetchProfiles]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
if (!search.trim()) {
|
if (!search.trim()) {
|
||||||
setFilteredProfiles(profiles);
|
setFilteredProfiles(profiles);
|
||||||
return;
|
return;
|
||||||
@@ -150,6 +156,19 @@ export function UsersPage() {
|
|||||||
);
|
);
|
||||||
}, [search, profiles]);
|
}, [search, profiles]);
|
||||||
|
|
||||||
|
const totalCount = filteredProfiles.length;
|
||||||
|
const paginatedProfiles = filteredProfiles.slice(
|
||||||
|
(page - 1) * pageSize,
|
||||||
|
page * pageSize
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -209,7 +228,7 @@ export function UsersPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredProfiles.map((profile) => (
|
paginatedProfiles.map((profile) => (
|
||||||
<TableRow key={profile.id}>
|
<TableRow key={profile.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link to={`/users/${profile.id}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
<Link to={`/users/${profile.id}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
||||||
@@ -294,6 +313,19 @@ export function UsersPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filteredProfiles.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ android {
|
|||||||
applicationId 'com.sauci.app'
|
applicationId 'com.sauci.app'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 17
|
versionCode 21
|
||||||
versionName "1.0.0"
|
versionName "1.0.0"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": false,
|
"supportsTablet": false,
|
||||||
"bundleIdentifier": "com.sauci.app",
|
"bundleIdentifier": "com.sauci.app",
|
||||||
"buildNumber": "24",
|
"buildNumber": "25",
|
||||||
"usesAppleSignIn": true,
|
"usesAppleSignIn": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"UIBackgroundModes": [
|
"UIBackgroundModes": [
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
"backgroundColor": "#1a1a2e"
|
"backgroundColor": "#1a1a2e"
|
||||||
},
|
},
|
||||||
"package": "com.sauci.app",
|
"package": "com.sauci.app",
|
||||||
"versionCode": 20,
|
"versionCode": 21,
|
||||||
"googleServicesFile": "./google-services.json",
|
"googleServicesFile": "./google-services.json",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import { supabase } from "../../src/lib/supabase";
|
|||||||
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
|
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
|
||||||
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
|
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
|
||||||
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
|
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
|
||||||
|
import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal";
|
||||||
import { Events } from "../../src/lib/analytics";
|
import { Events } from "../../src/lib/analytics";
|
||||||
import type { MatchWithQuestion } from "../../src/types";
|
import type { MatchWithQuestion, AnswerType } from "../../src/types";
|
||||||
import type { Database } from "../../src/types/supabase";
|
import type { Database } from "../../src/types/supabase";
|
||||||
|
|
||||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||||
@@ -143,8 +144,7 @@ export default function AppLayout() {
|
|||||||
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
|
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
|
||||||
const { fetchEnabledPacks } = usePacksStore();
|
const { fetchEnabledPacks } = usePacksStore();
|
||||||
const { initializeRevenueCat } = useSubscriptionStore();
|
const { initializeRevenueCat } = useSubscriptionStore();
|
||||||
const [matchNotification, setMatchNotification] = useState<MatchWithQuestion | null>(null);
|
const [matchNotification, setMatchNotification] = useState<MatchNotificationData | null>(null);
|
||||||
const matchModalAnim = useRef(new Animated.Value(0)).current;
|
|
||||||
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
||||||
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [isLocked, setIsLocked] = useState(false);
|
const [isLocked, setIsLocked] = useState(false);
|
||||||
@@ -381,10 +381,42 @@ export default function AppLayout() {
|
|||||||
Events.milestoneMatch(currentMatchCount + 1);
|
Events.milestoneMatch(currentMatchCount + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to store and show notification
|
// Add to store
|
||||||
addMatch(matchWithQuestion);
|
addMatch(matchWithQuestion);
|
||||||
setMatchNotification(matchWithQuestion);
|
|
||||||
Events.matchCreated(matchWithQuestion.match_type || "unknown");
|
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]);
|
}, [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(() => {
|
const dismissNotification = useCallback(() => {
|
||||||
Animated.timing(matchModalAnim, {
|
setMatchNotification(null);
|
||||||
toValue: 0,
|
}, []);
|
||||||
duration: 200,
|
|
||||||
useNativeDriver: true,
|
const handleMatchChat = useCallback(() => {
|
||||||
}).start(() => {
|
if (matchNotification) {
|
||||||
|
const matchId = matchNotification.id;
|
||||||
setMatchNotification(null);
|
setMatchNotification(null);
|
||||||
});
|
router.push(`/(app)/chat/${matchId}`);
|
||||||
}, [matchModalAnim]);
|
}
|
||||||
|
}, [matchNotification, router]);
|
||||||
|
|
||||||
const dismissMessageToast = useCallback(() => {
|
const dismissMessageToast = useCallback(() => {
|
||||||
Animated.timing(messageToastAnim, {
|
Animated.timing(messageToastAnim, {
|
||||||
@@ -786,53 +808,13 @@ export default function AppLayout() {
|
|||||||
})}
|
})}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Match Notification Overlay - Using Animated.View instead of Modal for Android compatibility */}
|
{/* Match Notification Modal - Premium styling with response details */}
|
||||||
{matchNotification && (
|
<MatchNotificationModal
|
||||||
<Animated.View
|
visible={!!matchNotification}
|
||||||
style={[
|
match={matchNotification}
|
||||||
styles.matchOverlay,
|
onDismiss={dismissNotification}
|
||||||
{
|
onChat={handleMatchChat}
|
||||||
opacity: matchModalAnim,
|
/>
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Pressable style={styles.matchOverlayBackground} onPress={dismissNotification} />
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
styles.modalContent,
|
|
||||||
{
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
scale: matchModalAnim.interpolate({
|
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: [0.8, 1],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={styles.modalHeader}>
|
|
||||||
<Ionicons name="heart" size={48} color={colors.primary} />
|
|
||||||
<Text style={styles.modalTitle}>It's a Match!</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.modalSubtitle}>You both agreed on:</Text>
|
|
||||||
<Text style={styles.modalQuestion}>
|
|
||||||
"{matchNotification?.question?.text}"
|
|
||||||
</Text>
|
|
||||||
<View style={styles.matchTypeBadge}>
|
|
||||||
<Text style={styles.matchTypeText}>
|
|
||||||
{matchNotification?.match_type === 'yes_yes' ? 'YES + YES' :
|
|
||||||
matchNotification?.match_type === 'yes_maybe' ? 'YES + MAYBE' :
|
|
||||||
'MAYBE + MAYBE'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Pressable style={styles.dismissButton} onPress={dismissNotification}>
|
|
||||||
<Text style={styles.dismissButtonText}>Nice!</Text>
|
|
||||||
</Pressable>
|
|
||||||
</Animated.View>
|
|
||||||
</Animated.View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Message Toast Notification */}
|
{/* Message Toast Notification */}
|
||||||
{lastMessage && (
|
{lastMessage && (
|
||||||
@@ -875,72 +857,6 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: colors.background,
|
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: {
|
messageToast: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: Platform.OS === 'ios' ? 50 : 20,
|
top: Platform.OS === 'ios' ? 50 : 20,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const ACCENT_COLOR = colors.primary;
|
|||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { user, partner, couple } = useAuthStore();
|
const { user, partner, couple } = useAuthStore();
|
||||||
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
|
const { matches, newMatchesCount, totalCount, fetchMatches } = useMatchStore();
|
||||||
const { enabledPackIds, fetchPacks } = usePacksStore();
|
const { enabledPackIds, fetchPacks } = usePacksStore();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ export default function HomeScreen() {
|
|||||||
color={matches.length > 0 ? colors.text : ACCENT_COLOR}
|
color={matches.length > 0 ? colors.text : ACCENT_COLOR}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.statNumber}>{matches.length}</Text>
|
<Text style={styles.statNumber}>{totalCount ?? matches.length}</Text>
|
||||||
<Text style={styles.statLabel}>Matches</Text>
|
<Text style={styles.statLabel}>Matches</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
||||||
|
import { SwipeableMatchItem } from "../../src/components/matches";
|
||||||
import { useAmbientOrbAnimation } from "../../src/hooks";
|
import { useAmbientOrbAnimation } from "../../src/hooks";
|
||||||
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
||||||
import { MatchesTutorial } from "../../src/components/tutorials";
|
import { MatchesTutorial } from "../../src/components/tutorials";
|
||||||
@@ -37,7 +38,23 @@ const HEADER_SCROLL_DISTANCE = 100;
|
|||||||
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
|
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
|
||||||
|
|
||||||
export default function MatchesScreen() {
|
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 { user } = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
@@ -150,84 +167,97 @@ export default function MatchesScreen() {
|
|||||||
|
|
||||||
const isYesYes = item.match_type === "yes_yes";
|
const isYesYes = item.match_type === "yes_yes";
|
||||||
|
|
||||||
|
const handleArchive = async () => {
|
||||||
|
if (showArchived) {
|
||||||
|
await unarchiveMatch(item.id);
|
||||||
|
} else {
|
||||||
|
await archiveMatch(item.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
|
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
|
||||||
<TouchableOpacity
|
<SwipeableMatchItem
|
||||||
onPress={() => router.push(`/chat/${item.id}`)}
|
onArchive={handleArchive}
|
||||||
activeOpacity={0.8}
|
isArchived={showArchived}
|
||||||
>
|
>
|
||||||
<View style={styles.matchCardPremium}>
|
<TouchableOpacity
|
||||||
{/* Subtle gradient background */}
|
onPress={() => router.push(`/chat/${item.id}`)}
|
||||||
<LinearGradient
|
activeOpacity={0.8}
|
||||||
colors={['rgba(22, 33, 62, 0.6)', 'rgba(13, 13, 26, 0.8)']}
|
>
|
||||||
style={StyleSheet.absoluteFill}
|
<View style={styles.matchCardPremium}>
|
||||||
start={{ x: 0, y: 0 }}
|
{/* Subtle gradient background */}
|
||||||
end={{ x: 1, y: 1 }}
|
<LinearGradient
|
||||||
/>
|
colors={['rgba(22, 33, 62, 0.6)', 'rgba(13, 13, 26, 0.8)']}
|
||||||
{/* Top silk highlight */}
|
style={StyleSheet.absoluteFill}
|
||||||
<LinearGradient
|
start={{ x: 0, y: 0 }}
|
||||||
colors={[`${ACCENT_RGBA}0.06)`, 'transparent']}
|
end={{ x: 1, y: 1 }}
|
||||||
style={styles.cardSilkHighlight}
|
/>
|
||||||
start={{ x: 0.5, y: 0 }}
|
{/* Top silk highlight */}
|
||||||
end={{ x: 0.5, y: 1 }}
|
<LinearGradient
|
||||||
/>
|
colors={[`${ACCENT_RGBA}0.06)`, 'transparent']}
|
||||||
|
style={styles.cardSilkHighlight}
|
||||||
|
start={{ x: 0.5, y: 0 }}
|
||||||
|
end={{ x: 0.5, y: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
<View style={styles.matchRow}>
|
<View style={styles.matchRow}>
|
||||||
{/* Premium icon container */}
|
{/* Premium icon container */}
|
||||||
<View style={[
|
<View style={[
|
||||||
styles.iconContainerPremium,
|
styles.iconContainerPremium,
|
||||||
isYesYes && styles.iconContainerYesYes
|
isYesYes && styles.iconContainerYesYes
|
||||||
]}>
|
]}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={isYesYes ? "heart" : "heart-half"}
|
name={isYesYes ? "heart" : "heart-half"}
|
||||||
size={20}
|
size={20}
|
||||||
color={isYesYes ? ACCENT : ROSE}
|
color={isYesYes ? ACCENT : ROSE}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<Text style={styles.questionTextPremium} numberOfLines={2}>
|
<Text style={styles.questionTextPremium} numberOfLines={2}>
|
||||||
{userText}
|
{userText}
|
||||||
</Text>
|
|
||||||
{item.question.partner_text && (
|
|
||||||
<Text style={styles.partnerTextPremium} numberOfLines={1}>
|
|
||||||
Partner: {partnerText}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
{item.question.partner_text && (
|
||||||
<View style={styles.metaRow}>
|
<Text style={styles.partnerTextPremium} numberOfLines={1}>
|
||||||
<View style={[
|
Partner: {partnerText}
|
||||||
styles.tagPremium,
|
</Text>
|
||||||
isYesYes && styles.tagPremiumHighlight
|
)}
|
||||||
]}>
|
<View style={styles.metaRow}>
|
||||||
<Text style={[
|
<View style={[
|
||||||
styles.tagTextPremium,
|
styles.tagPremium,
|
||||||
isYesYes && styles.tagTextPremiumHighlight
|
isYesYes && styles.tagPremiumHighlight
|
||||||
]}>
|
]}>
|
||||||
{isYesYes ? "YES + YES" : "YES + MAYBE"}
|
<Text style={[
|
||||||
|
styles.tagTextPremium,
|
||||||
|
isYesYes && styles.tagTextPremiumHighlight
|
||||||
|
]}>
|
||||||
|
{isYesYes ? "YES + YES" : "YES + MAYBE"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.datePremium}>
|
||||||
|
{new Date(item.created_at).toLocaleDateString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.datePremium}>
|
|
||||||
{new Date(item.created_at).toLocaleDateString()}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.rightSection}>
|
<View style={styles.rightSection}>
|
||||||
{item.unreadCount > 0 && (
|
{item.unreadCount > 0 && (
|
||||||
<View style={styles.unreadBadgePremium}>
|
<View style={styles.unreadBadgePremium}>
|
||||||
<Ionicons name="chatbubble" size={10} color={colors.text} />
|
<Ionicons name="chatbubble" size={10} color={colors.text} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.chevronContainerPremium}>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color={`${ACCENT_RGBA}0.6)`} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
<View style={styles.chevronContainerPremium}>
|
|
||||||
<Ionicons name="chevron-forward" size={16} color={`${ACCENT_RGBA}0.6)`} />
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Premium border */}
|
{/* Premium border */}
|
||||||
<View style={styles.cardPremiumBorder} pointerEvents="none" />
|
<View style={styles.cardPremiumBorder} pointerEvents="none" />
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</SwipeableMatchItem>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -296,7 +326,7 @@ export default function MatchesScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<AnimatedFlatList
|
<AnimatedFlatList
|
||||||
data={matches}
|
data={showArchived ? archivedMatches : matches}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={(item: any) => item.id}
|
keyExtractor={(item: any) => item.id}
|
||||||
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
||||||
@@ -328,50 +358,122 @@ export default function MatchesScreen() {
|
|||||||
{/* Decorative separator */}
|
{/* Decorative separator */}
|
||||||
<DecorativeSeparator variant="gold" />
|
<DecorativeSeparator variant="gold" />
|
||||||
|
|
||||||
|
{/* Filter toggle */}
|
||||||
|
<View style={styles.filterContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.filterTab, !showArchived && styles.filterTabActive]}
|
||||||
|
onPress={() => showArchived && toggleShowArchived()}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="heart"
|
||||||
|
size={14}
|
||||||
|
color={!showArchived ? ACCENT : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.filterTabText, !showArchived && styles.filterTabTextActive]}>
|
||||||
|
Active
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.filterTab, showArchived && styles.filterTabActive]}
|
||||||
|
onPress={() => {
|
||||||
|
if (!showArchived) {
|
||||||
|
toggleShowArchived();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{isLoadingArchived ? (
|
||||||
|
<ActivityIndicator size="small" color={ACCENT} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons
|
||||||
|
name="archive-outline"
|
||||||
|
size={14}
|
||||||
|
color={showArchived ? ACCENT : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.filterTabText, showArchived && styles.filterTabTextActive]}>
|
||||||
|
Archived
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Count badge */}
|
{/* Count badge */}
|
||||||
<View style={styles.countBadgePremium}>
|
<View style={styles.countBadgePremium}>
|
||||||
<Ionicons name="heart" size={12} color={ACCENT} />
|
<Ionicons name={showArchived ? "archive" : "heart"} size={12} color={ACCENT} />
|
||||||
<Text style={styles.countTextPremium}>{totalCount ?? matches.length} {(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'}</Text>
|
<Text style={styles.countTextPremium}>
|
||||||
|
{showArchived
|
||||||
|
? `${archivedMatches.length} ARCHIVED`
|
||||||
|
: `${totalCount ?? matches.length} ${(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'}`
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<Animated.View
|
showArchived ? (
|
||||||
entering={FadeInUp.duration(600).springify()}
|
<Animated.View
|
||||||
style={styles.emptyContent}
|
entering={FadeInUp.duration(600).springify()}
|
||||||
>
|
style={styles.emptyContent}
|
||||||
{/* Description */}
|
|
||||||
<Text style={styles.emptyDescription}>
|
|
||||||
Answer questions together to discover what you both enjoy. Matches appear when you agree!
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Feature hints */}
|
|
||||||
<View style={styles.emptyFeatures}>
|
|
||||||
<View style={styles.emptyFeatureItem}>
|
|
||||||
<Ionicons name="heart" size={16} color={ACCENT} />
|
|
||||||
<Text style={styles.emptyFeatureText}>Swipe right for yes</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.emptyFeatureItem}>
|
|
||||||
<Ionicons name="sparkles" size={16} color={ACCENT} />
|
|
||||||
<Text style={styles.emptyFeatureText}>Match when you both agree</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.emptyFeatureItem}>
|
|
||||||
<Ionicons name="chatbubbles-outline" size={16} color={ACCENT} />
|
|
||||||
<Text style={styles.emptyFeatureText}>Chat about your matches</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Bottom teaser */}
|
|
||||||
<Text style={styles.emptyTeaser}>Your first match is just a swipe away</Text>
|
|
||||||
|
|
||||||
<GlassButton
|
|
||||||
onPress={() => router.push("/(app)/swipe")}
|
|
||||||
style={{ marginTop: spacing.lg }}
|
|
||||||
>
|
>
|
||||||
Start Swiping
|
{/* Archive icon */}
|
||||||
</GlassButton>
|
<View style={styles.emptyIconContainer}>
|
||||||
</Animated.View>
|
<Ionicons name="archive-outline" size={48} color={ACCENT} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Text style={styles.emptyTitle}>No Archived Matches</Text>
|
||||||
|
<Text style={styles.emptyDescription}>
|
||||||
|
Matches you archive will appear here. Swipe left on any match to archive it.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<GlassButton
|
||||||
|
onPress={() => toggleShowArchived()}
|
||||||
|
style={{ marginTop: spacing.lg }}
|
||||||
|
>
|
||||||
|
View Active Matches
|
||||||
|
</GlassButton>
|
||||||
|
</Animated.View>
|
||||||
|
) : (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInUp.duration(600).springify()}
|
||||||
|
style={styles.emptyContent}
|
||||||
|
>
|
||||||
|
{/* Description */}
|
||||||
|
<Text style={styles.emptyDescription}>
|
||||||
|
Answer questions together to discover what you both enjoy. Matches appear when you agree!
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Feature hints */}
|
||||||
|
<View style={styles.emptyFeatures}>
|
||||||
|
<View style={styles.emptyFeatureItem}>
|
||||||
|
<Ionicons name="heart" size={16} color={ACCENT} />
|
||||||
|
<Text style={styles.emptyFeatureText}>Swipe right for yes</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.emptyFeatureItem}>
|
||||||
|
<Ionicons name="sparkles" size={16} color={ACCENT} />
|
||||||
|
<Text style={styles.emptyFeatureText}>Match when you both agree</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.emptyFeatureItem}>
|
||||||
|
<Ionicons name="chatbubbles-outline" size={16} color={ACCENT} />
|
||||||
|
<Text style={styles.emptyFeatureText}>Chat about your matches</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bottom teaser */}
|
||||||
|
<Text style={styles.emptyTeaser}>Your first match is just a swipe away</Text>
|
||||||
|
|
||||||
|
<GlassButton
|
||||||
|
onPress={() => router.push("/(app)/swipe")}
|
||||||
|
style={{ marginTop: spacing.lg }}
|
||||||
|
>
|
||||||
|
Start Swiping
|
||||||
|
</GlassButton>
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -478,6 +580,36 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.text,
|
color: colors.text,
|
||||||
textAlign: 'center',
|
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: {
|
countBadgePremium: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -628,6 +760,22 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.xl,
|
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: {
|
emptyDescription: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
|
|||||||
type GroupByOption = "pack" | "date" | "answer";
|
type GroupByOption = "pack" | "date" | "answer";
|
||||||
|
|
||||||
export default function MyAnswersScreen() {
|
export default function MyAnswersScreen() {
|
||||||
const { responses, isLoading, isLoadingMore, hasMore, groupBy, fetchResponses, setGroupBy, totalCount } = useResponsesStore();
|
const { responses, isLoading, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
@@ -123,7 +123,7 @@ export default function MyAnswersScreen() {
|
|||||||
}
|
}
|
||||||
}, [isLoading, isLoadingMore, hasMore]);
|
}, [isLoading, isLoadingMore, hasMore]);
|
||||||
|
|
||||||
const sections = groupResponses(responses, groupBy);
|
const sections = groupResponses(responses, groupBy, dateSortOrder);
|
||||||
|
|
||||||
const handleEditPress = (response: ResponseWithQuestion) => {
|
const handleEditPress = (response: ResponseWithQuestion) => {
|
||||||
setSelectedResponse(response);
|
setSelectedResponse(response);
|
||||||
@@ -164,16 +164,39 @@ export default function MyAnswersScreen() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => (
|
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => {
|
||||||
<TouchableOpacity
|
const isActive = groupBy === option;
|
||||||
style={[styles.groupByButton, groupBy === option && styles.groupByButtonActive]}
|
const isDateButton = option === "date";
|
||||||
onPress={() => setGroupBy(option)}
|
const showChevron = isDateButton && isActive;
|
||||||
>
|
|
||||||
<Text style={[styles.groupByText, groupBy === option && styles.groupByTextActive]}>
|
const handlePress = () => {
|
||||||
{label}
|
if (isDateButton && isActive) {
|
||||||
</Text>
|
// Toggle sort order when date is already selected
|
||||||
</TouchableOpacity>
|
toggleDateSortOrder();
|
||||||
);
|
} else {
|
||||||
|
setGroupBy(option);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.groupByButton, isActive && styles.groupByButtonActive]}
|
||||||
|
onPress={handlePress}
|
||||||
|
>
|
||||||
|
<Text style={[styles.groupByText, isActive && styles.groupByTextActive]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{showChevron && (
|
||||||
|
<Ionicons
|
||||||
|
name={dateSortOrder === "newest" ? "chevron-down" : "chevron-up"}
|
||||||
|
size={14}
|
||||||
|
color={ACCENT}
|
||||||
|
style={styles.sortChevron}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
@@ -441,6 +464,8 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: spacing.md,
|
marginTop: spacing.md,
|
||||||
},
|
},
|
||||||
groupByButton: {
|
groupByButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
@@ -461,6 +486,9 @@ const styles = StyleSheet.create({
|
|||||||
color: ACCENT,
|
color: ACCENT,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
},
|
},
|
||||||
|
sortChevron: {
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
},
|
||||||
// Section header
|
// Section header
|
||||||
sectionHeader: {
|
sectionHeader: {
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
|
|||||||
@@ -24,9 +24,18 @@ import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // P
|
|||||||
import { SwipeTutorial } from "../../src/components/tutorials";
|
import { SwipeTutorial } from "../../src/components/tutorials";
|
||||||
import { QuestionFeedbackModal } from "../../src/components/feedback";
|
import { QuestionFeedbackModal } from "../../src/components/feedback";
|
||||||
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
||||||
|
import { Paywall } from "../../src/components/paywall";
|
||||||
import { Events } from "../../src/lib/analytics";
|
import { Events } from "../../src/lib/analytics";
|
||||||
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
||||||
|
|
||||||
|
interface DailyLimitInfo {
|
||||||
|
responses_today: number;
|
||||||
|
limit_value: number;
|
||||||
|
remaining: number;
|
||||||
|
reset_at: string;
|
||||||
|
is_blocked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SwipeScreen() {
|
export default function SwipeScreen() {
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
// Normalize packId - useLocalSearchParams can return string | string[]
|
// Normalize packId - useLocalSearchParams can return string | string[]
|
||||||
@@ -38,6 +47,8 @@ export default function SwipeScreen() {
|
|||||||
const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null);
|
const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null);
|
||||||
const [isBlocked, setIsBlocked] = useState(false);
|
const [isBlocked, setIsBlocked] = useState(false);
|
||||||
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
|
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
|
||||||
|
const [dailyLimitInfo, setDailyLimitInfo] = useState<DailyLimitInfo | null>(null);
|
||||||
|
const [showPaywall, setShowPaywall] = useState(false);
|
||||||
const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore();
|
const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore();
|
||||||
const { partner, couple } = useAuthStore();
|
const { partner, couple } = useAuthStore();
|
||||||
const hasTrackedExhausted = useRef(false);
|
const hasTrackedExhausted = useRef(false);
|
||||||
@@ -49,6 +60,40 @@ export default function SwipeScreen() {
|
|||||||
// Progress bar shimmer animation
|
// Progress bar shimmer animation
|
||||||
const shimmerPosition = useSharedValue(-1);
|
const shimmerPosition = useSharedValue(-1);
|
||||||
|
|
||||||
|
// Countdown for daily limit reset
|
||||||
|
const [countdown, setCountdown] = useState<string>("");
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
// Progress bar shimmer sweep - 2.5 second cycle
|
// Progress bar shimmer sweep - 2.5 second cycle
|
||||||
shimmerPosition.value = withRepeat(
|
shimmerPosition.value = withRepeat(
|
||||||
@@ -139,16 +184,48 @@ export default function SwipeScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate effective total questions (accounting for gap limit)
|
// Check if user has reached daily response limit
|
||||||
const effectiveTotal = (() => {
|
const checkDailyLimit = async (): Promise<boolean> => {
|
||||||
if (!gapInfo || gapInfo.threshold === 0) {
|
try {
|
||||||
// No gap limit active, show all questions
|
const { data, error } = await supabase.rpc('get_daily_response_limit_status');
|
||||||
return questions.length;
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
// remaining = how many more questions until blocked
|
};
|
||||||
const remaining = gapInfo.threshold - gapInfo.unanswered;
|
|
||||||
// effective total = current position + remaining (capped by actual questions)
|
// Calculate effective total questions (accounting for gap limit AND daily limit)
|
||||||
return Math.min(questions.length, currentIndex + Math.max(0, remaining));
|
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
|
// Track when all questions are exhausted
|
||||||
@@ -212,14 +289,14 @@ export default function SwipeScreen() {
|
|||||||
// Filter out recently skipped questions
|
// Filter out recently skipped questions
|
||||||
const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id));
|
const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id));
|
||||||
|
|
||||||
// Always check answer gap when user has partner and not viewing specific pack
|
// Always check answer gap and daily limit when user has partner and not viewing specific pack
|
||||||
// This gives us the gap info for accurate counter display
|
|
||||||
if (partner && !packId) {
|
if (partner && !packId) {
|
||||||
await checkAnswerGap();
|
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
|
||||||
} else {
|
} else {
|
||||||
// Reset blocked state if no partner or viewing specific pack
|
// Reset blocked state if no partner or viewing specific pack
|
||||||
setIsBlocked(false);
|
setIsBlocked(false);
|
||||||
setGapInfo(null);
|
setGapInfo(null);
|
||||||
|
setDailyLimitInfo(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server provides ordering (daily seeded random with priority)
|
// Server provides ordering (daily seeded random with priority)
|
||||||
@@ -264,8 +341,19 @@ export default function SwipeScreen() {
|
|||||||
console.error("Submit response error:", error);
|
console.error("Submit response error:", error);
|
||||||
} else {
|
} else {
|
||||||
Events.questionAnswered(answer, question.pack_id);
|
Events.questionAnswered(answer, question.pack_id);
|
||||||
// Check if we've hit the answer gap threshold
|
|
||||||
await checkAnswerGap();
|
// 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) {
|
} catch (error) {
|
||||||
console.error("Failed to submit response", error);
|
console.error("Failed to submit response", error);
|
||||||
@@ -350,6 +438,117 @@ export default function SwipeScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show "daily limit reached" screen when user has exhausted their daily quota
|
||||||
|
if (dailyLimitInfo?.is_blocked) {
|
||||||
|
const LIMIT_ACCENT = colors.premium.gold;
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
{/* Ambient Orbs - Gold/warm glow */}
|
||||||
|
<Animated.View style={[styles.ambientOrb, styles.dailyLimitOrbTop, orbStyle1]} pointerEvents="none">
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.premium.goldGlow, 'transparent']}
|
||||||
|
style={styles.orbGradient}
|
||||||
|
start={{ x: 0.5, y: 0.5 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
<Animated.View style={[styles.ambientOrb, styles.dailyLimitOrbBottom, orbStyle2]} pointerEvents="none">
|
||||||
|
<LinearGradient
|
||||||
|
colors={['rgba(212, 175, 55, 0.15)', 'transparent']}
|
||||||
|
style={styles.orbGradient}
|
||||||
|
start={{ x: 0.5, y: 0.5 }}
|
||||||
|
end={{ x: 0, y: 0 }}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInUp.duration(600).springify()}
|
||||||
|
style={styles.waitingContent}
|
||||||
|
>
|
||||||
|
{/* Icon with glow effect */}
|
||||||
|
<View style={styles.dailyLimitIconWrapper}>
|
||||||
|
<View style={styles.dailyLimitIconGlow} />
|
||||||
|
<View style={styles.dailyLimitIconContainer}>
|
||||||
|
<Ionicons name="hourglass-outline" size={36} color={LIMIT_ACCENT} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Title section */}
|
||||||
|
<Text style={styles.dailyLimitLabel}>DAILY LIMIT REACHED</Text>
|
||||||
|
<Text style={styles.dailyLimitTitle}>Take a Breather</Text>
|
||||||
|
|
||||||
|
<DecorativeSeparator variant="gold" />
|
||||||
|
|
||||||
|
{/* Countdown timer - prominent display */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.delay(300).duration(400)}
|
||||||
|
style={styles.countdownContainer}
|
||||||
|
>
|
||||||
|
<Text style={styles.countdownLabel}>REFRESHES IN</Text>
|
||||||
|
<View style={styles.countdownBadge}>
|
||||||
|
<Ionicons name="timer-outline" size={20} color={LIMIT_ACCENT} />
|
||||||
|
<Text style={styles.countdownText}>{countdown}</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Text style={styles.dailyLimitDescription}>
|
||||||
|
You've answered {dailyLimitInfo.limit_value} questions today!{'\n'}
|
||||||
|
Let the anticipation build while you wait.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Feature hints - vertical list */}
|
||||||
|
<View style={styles.waitingFeatures}>
|
||||||
|
<View style={styles.waitingFeatureItem}>
|
||||||
|
<Ionicons name="chatbubbles-outline" size={16} color={colors.premium.gold} />
|
||||||
|
<Text style={styles.waitingFeatureText}>Chat about your matches</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.waitingFeatureItem}>
|
||||||
|
<Ionicons name="sparkles" size={16} color={colors.premium.gold} />
|
||||||
|
<Text style={styles.waitingFeatureText}>Fresh questions tomorrow</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.waitingFeatureItem}>
|
||||||
|
<Ionicons name="infinite-outline" size={16} color={colors.premium.gold} />
|
||||||
|
<Text style={styles.waitingFeatureText}>Go unlimited with Premium</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Premium upsell button */}
|
||||||
|
<Text style={styles.premiumUpsellText}>Want to keep exploring?</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowPaywall(true)}
|
||||||
|
style={styles.premiumButton}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.premium.gold, colors.premium.goldDark]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.premiumButtonGradient}
|
||||||
|
/>
|
||||||
|
<View style={styles.premiumButtonContent}>
|
||||||
|
<Ionicons name="infinite" size={18} color="#000" />
|
||||||
|
<Text style={styles.premiumButtonText}>Unlock Unlimited</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Paywall Modal */}
|
||||||
|
<Paywall
|
||||||
|
visible={showPaywall}
|
||||||
|
onClose={() => setShowPaywall(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowPaywall(false);
|
||||||
|
// Refresh to clear limit status
|
||||||
|
fetchQuestions();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Show "waiting for partner" message when user is too far ahead
|
// Show "waiting for partner" message when user is too far ahead
|
||||||
if (isBlocked && gapInfo) {
|
if (isBlocked && gapInfo) {
|
||||||
const WAITING_ACCENT = colors.premium.rose;
|
const WAITING_ACCENT = colors.premium.rose;
|
||||||
@@ -656,6 +855,17 @@ export default function SwipeScreen() {
|
|||||||
questionId={feedbackQuestion?.id || ''}
|
questionId={feedbackQuestion?.id || ''}
|
||||||
questionText={feedbackQuestion?.text || ''}
|
questionText={feedbackQuestion?.text || ''}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Paywall Modal */}
|
||||||
|
<Paywall
|
||||||
|
visible={showPaywall}
|
||||||
|
onClose={() => setShowPaywall(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowPaywall(false);
|
||||||
|
// Refresh to clear limit status
|
||||||
|
fetchQuestions();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</GradientBackground>
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -889,4 +1099,113 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginBottom: spacing.xs,
|
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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20</string>
|
<string>25</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0</string>
|
<string>12.0</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Sauci uses your camera to take photos and videos that you can share privately with your partner in encrypted chat messages.</string>
|
<string>Sauci uses your camera to take photos and videos that you can share privately with your partner.</string>
|
||||||
<key>NSFaceIDUsageDescription</key>
|
<key>NSFaceIDUsageDescription</key>
|
||||||
<string>Sauci uses Face ID to unlock the app and keep your conversations private.</string>
|
<string>Sauci uses Face ID to unlock the app and keep your conversations private.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
<string>Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.</string>
|
<string>Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Sauci uses your photo library so you can select photos and videos to share privately with your partner in encrypted chat messages.</string>
|
<string>Sauci uses your photo library so you can select photos and videos to share privately with your partner.</string>
|
||||||
<key>NSUserActivityTypes</key>
|
<key>NSUserActivityTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<GlassButton>Click Me</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Click Me')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with primary variant by default', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassButton>Primary Button</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = getByText('Primary Button');
|
||||||
|
expect(text).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders secondary variant', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassButton variant="secondary">Secondary Button</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Secondary Button')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ghost variant', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassButton variant="ghost">Ghost Button</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Ghost Button')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders danger variant', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassButton variant="danger">Danger Button</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Danger Button')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with icon when provided', () => {
|
||||||
|
const TestIcon = () => <Text testID="test-icon">Icon</Text>;
|
||||||
|
const { getByTestId, getByText } = render(
|
||||||
|
<GlassButton icon={<TestIcon />}>With Icon</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('test-icon')).toBeTruthy();
|
||||||
|
expect(getByText('With Icon')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading indicator when loading is true', () => {
|
||||||
|
const { queryByText, UNSAFE_getByType } = render(
|
||||||
|
<GlassButton loading>Loading Button</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<GlassButton size="sm">Small</GlassButton>
|
||||||
|
);
|
||||||
|
expect(getByText('Small')).toBeTruthy();
|
||||||
|
|
||||||
|
rerender(<GlassButton size="md">Medium</GlassButton>);
|
||||||
|
expect(getByText('Medium')).toBeTruthy();
|
||||||
|
|
||||||
|
rerender(<GlassButton size="lg">Large</GlassButton>);
|
||||||
|
expect(getByText('Large')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('press handling', () => {
|
||||||
|
it('calls onPress when pressed', async () => {
|
||||||
|
const onPress = jest.fn();
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassButton onPress={onPress} haptic={false}>Press Me</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<GlassButton onPress={onPress} disabled>
|
||||||
|
Disabled Button
|
||||||
|
</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.press(getByText('Disabled Button'));
|
||||||
|
|
||||||
|
expect(onPress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onPress when loading', () => {
|
||||||
|
const onPress = jest.fn();
|
||||||
|
const { UNSAFE_getByType } = render(
|
||||||
|
<GlassButton onPress={onPress} loading>
|
||||||
|
Loading Button
|
||||||
|
</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<GlassButton haptic>Haptic Button</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Haptic Button')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onPress when haptic is false', async () => {
|
||||||
|
const onPress = jest.fn();
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassButton onPress={onPress} haptic={false}>
|
||||||
|
No Haptic
|
||||||
|
</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.press(getByText('No Haptic'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onPress).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('styling', () => {
|
||||||
|
it('applies fullWidth style when fullWidth is true', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassButton fullWidth>Full Width Button</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<GlassButton style={customStyle}>Styled Button</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Styled Button')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom textStyle prop', () => {
|
||||||
|
const customTextStyle = { fontWeight: 'bold' as const };
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassButton textStyle={customTextStyle}>Styled Text</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Styled Text')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies disabled opacity when disabled', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassButton disabled>Disabled Button</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<GlassButton>Animated Button</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<GlassButton>Animated Button</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(<GlassButton>Default</GlassButton>);
|
||||||
|
expect(getByText('Default')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has default size of md', () => {
|
||||||
|
const { getByText } = render(<GlassButton>Default Size</GlassButton>);
|
||||||
|
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(<GlassButton>Default Haptic</GlassButton>);
|
||||||
|
expect(getByText('Default Haptic')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has disabled false by default', async () => {
|
||||||
|
const onPress = jest.fn();
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassButton onPress={onPress} haptic={false}>Not Disabled</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.press(getByText('Not Disabled'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onPress).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has loading false by default', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassButton>Not Loading</GlassButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Not Loading')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has fullWidth false by default', () => {
|
||||||
|
const { getByText } = render(<GlassButton>Not Full Width</GlassButton>);
|
||||||
|
expect(getByText('Not Full Width')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
<GlassCard>
|
||||||
|
<Text>Card Content</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Card Content')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders multiple children', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassCard>
|
||||||
|
<Text>First Child</Text>
|
||||||
|
<Text>Second Child</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('First Child')).toBeTruthy();
|
||||||
|
expect(getByText('Second Child')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nested components', () => {
|
||||||
|
const { getByText, getByTestId } = render(
|
||||||
|
<GlassCard>
|
||||||
|
<Text testID="nested-text">Nested Content</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<GlassCard>
|
||||||
|
<Text>iOS Card</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<GlassCard>
|
||||||
|
<Text>Card Content</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Card Content')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('intensity prop', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(Platform, 'OS', {
|
||||||
|
get: () => 'ios',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies light intensity', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<GlassCard intensity="light">
|
||||||
|
<Text>Light Card</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
const blurView = getByTestId('blur-view');
|
||||||
|
expect(blurView).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies medium intensity by default', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<GlassCard>
|
||||||
|
<Text>Medium Card</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
const blurView = getByTestId('blur-view');
|
||||||
|
expect(blurView).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies heavy intensity', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<GlassCard intensity="heavy">
|
||||||
|
<Text>Heavy Card</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
const blurView = getByTestId('blur-view');
|
||||||
|
expect(blurView).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('variant prop', () => {
|
||||||
|
it('renders default variant', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassCard variant="default">
|
||||||
|
<Text>Default Card</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Default Card')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders elevated variant', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassCard variant="elevated">
|
||||||
|
<Text>Elevated Card</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Elevated Card')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders subtle variant', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassCard variant="subtle">
|
||||||
|
<Text>Subtle Card</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Subtle Card')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('noPadding prop', () => {
|
||||||
|
it('has padding by default', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassCard>
|
||||||
|
<Text>Padded Card</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Padded Card')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes padding when noPadding is true', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassCard noPadding>
|
||||||
|
<Text>No Padding Card</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('No Padding Card')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('style prop', () => {
|
||||||
|
it('applies custom style', () => {
|
||||||
|
const customStyle = { marginTop: 20, backgroundColor: 'red' };
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassCard style={customStyle}>
|
||||||
|
<Text>Styled Card</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Styled Card')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges custom style with default styles', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassCard style={{ marginBottom: 10 }}>
|
||||||
|
<Text>Merged Styles Card</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Merged Styles Card')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('default props', () => {
|
||||||
|
it('has default intensity of medium', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassCard>
|
||||||
|
<Text>Default Intensity</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Default Intensity')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has default variant of default', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassCard>
|
||||||
|
<Text>Default Variant</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Default Variant')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has noPadding false by default', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassCard>
|
||||||
|
<Text>Has Padding</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Has Padding')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gradient highlight', () => {
|
||||||
|
it('renders highlight gradient', () => {
|
||||||
|
Object.defineProperty(Platform, 'OS', {
|
||||||
|
get: () => 'ios',
|
||||||
|
});
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassCard>
|
||||||
|
<Text>Card with Highlight</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<GlassCard>
|
||||||
|
<Text accessibilityLabel="Accessible content">Content</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<GlassCard
|
||||||
|
intensity="heavy"
|
||||||
|
variant="elevated"
|
||||||
|
noPadding
|
||||||
|
style={customStyle}
|
||||||
|
>
|
||||||
|
<Text>Full Props Card</Text>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Full Props Card')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
<GlassInput placeholder="Enter text" />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByPlaceholderText('Enter text')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with label', () => {
|
||||||
|
const { getByText, getByPlaceholderText } = render(
|
||||||
|
<GlassInput label="Username" placeholder="Enter username" />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Username')).toBeTruthy();
|
||||||
|
expect(getByPlaceholderText('Enter username')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders without label', () => {
|
||||||
|
const { queryByText, getByPlaceholderText } = render(
|
||||||
|
<GlassInput placeholder="No label input" />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByPlaceholderText('No label input')).toBeTruthy();
|
||||||
|
// No label text should be present
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders error message when provided', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<GlassInput placeholder="Input" error="This field is required" />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('This field is required')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render error message when not provided', () => {
|
||||||
|
const { queryByText, getByPlaceholderText } = render(
|
||||||
|
<GlassInput placeholder="No error input" />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByPlaceholderText('No error input')).toBeTruthy();
|
||||||
|
expect(queryByText(/error/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with icon', () => {
|
||||||
|
const TestIcon = () => <Text testID="input-icon">Icon</Text>;
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<GlassInput placeholder="Input with icon" icon={<TestIcon />} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('input-icon')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('platform behavior', () => {
|
||||||
|
it('uses BlurView on iOS', () => {
|
||||||
|
Object.defineProperty(Platform, 'OS', {
|
||||||
|
get: () => 'ios',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<GlassInput placeholder="iOS Input" />
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<GlassInput placeholder="Input" />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByPlaceholderText('Input')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('input interactions', () => {
|
||||||
|
it('handles text input changes', () => {
|
||||||
|
const onChangeText = jest.fn();
|
||||||
|
const { getByPlaceholderText } = render(
|
||||||
|
<GlassInput placeholder="Type here" onChangeText={onChangeText} />
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<GlassInput placeholder="Focus me" onFocus={onFocus} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = getByPlaceholderText('Focus me');
|
||||||
|
fireEvent(input, 'focus', {});
|
||||||
|
|
||||||
|
expect(onFocus).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles blur events', () => {
|
||||||
|
const onBlur = jest.fn();
|
||||||
|
const { getByPlaceholderText } = render(
|
||||||
|
<GlassInput placeholder="Blur me" onBlur={onBlur} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = getByPlaceholderText('Blur me');
|
||||||
|
fireEvent(input, 'blur', {});
|
||||||
|
|
||||||
|
expect(onBlur).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays value prop', () => {
|
||||||
|
const { getByDisplayValue } = render(
|
||||||
|
<GlassInput placeholder="Input" value="Initial value" />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByDisplayValue('Initial value')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles controlled input', () => {
|
||||||
|
const onChangeText = jest.fn();
|
||||||
|
const { getByDisplayValue, rerender } = render(
|
||||||
|
<GlassInput
|
||||||
|
placeholder="Controlled"
|
||||||
|
value="Initial"
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByDisplayValue('Initial')).toBeTruthy();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<GlassInput
|
||||||
|
placeholder="Controlled"
|
||||||
|
value="Updated"
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByDisplayValue('Updated')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('focus state styling', () => {
|
||||||
|
it('updates styling on focus', () => {
|
||||||
|
const { getByPlaceholderText } = render(
|
||||||
|
<GlassInput placeholder="Focus styling" />
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<GlassInput placeholder="Blur styling" />
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<GlassInput
|
||||||
|
placeholder="Error input"
|
||||||
|
error="Invalid input"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByPlaceholderText('Error input')).toBeTruthy();
|
||||||
|
expect(getByText('Invalid input')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies error border color', () => {
|
||||||
|
const { getByPlaceholderText } = render(
|
||||||
|
<GlassInput placeholder="Error border" error="Has error" />
|
||||||
|
);
|
||||||
|
|
||||||
|
// The component renders with error styling
|
||||||
|
expect(getByPlaceholderText('Error border')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('input props passthrough', () => {
|
||||||
|
it('passes secureTextEntry prop', () => {
|
||||||
|
const { getByPlaceholderText } = render(
|
||||||
|
<GlassInput placeholder="Password" secureTextEntry />
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = getByPlaceholderText('Password');
|
||||||
|
expect(input.props.secureTextEntry).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes keyboardType prop', () => {
|
||||||
|
const { getByPlaceholderText } = render(
|
||||||
|
<GlassInput placeholder="Email" keyboardType="email-address" />
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = getByPlaceholderText('Email');
|
||||||
|
expect(input.props.keyboardType).toBe('email-address');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes autoCapitalize prop', () => {
|
||||||
|
const { getByPlaceholderText } = render(
|
||||||
|
<GlassInput placeholder="Name" autoCapitalize="words" />
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = getByPlaceholderText('Name');
|
||||||
|
expect(input.props.autoCapitalize).toBe('words');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes autoCorrect prop', () => {
|
||||||
|
const { getByPlaceholderText } = render(
|
||||||
|
<GlassInput placeholder="Code" autoCorrect={false} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = getByPlaceholderText('Code');
|
||||||
|
expect(input.props.autoCorrect).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes maxLength prop', () => {
|
||||||
|
const { getByPlaceholderText } = render(
|
||||||
|
<GlassInput placeholder="Short" maxLength={10} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = getByPlaceholderText('Short');
|
||||||
|
expect(input.props.maxLength).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes multiline prop', () => {
|
||||||
|
const { getByPlaceholderText } = render(
|
||||||
|
<GlassInput placeholder="Message" multiline />
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = getByPlaceholderText('Message');
|
||||||
|
expect(input.props.multiline).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes editable prop', () => {
|
||||||
|
const { getByPlaceholderText } = render(
|
||||||
|
<GlassInput placeholder="Read only" editable={false} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = getByPlaceholderText('Read only');
|
||||||
|
expect(input.props.editable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes returnKeyType prop', () => {
|
||||||
|
const { getByPlaceholderText } = render(
|
||||||
|
<GlassInput placeholder="Search" returnKeyType="search" />
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<GlassInput
|
||||||
|
placeholder="Styled container"
|
||||||
|
containerStyle={customStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByPlaceholderText('Styled container')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('supports accessibilityLabel', () => {
|
||||||
|
const { getByLabelText } = render(
|
||||||
|
<GlassInput
|
||||||
|
placeholder="Accessible input"
|
||||||
|
accessibilityLabel="Username input field"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByLabelText('Username input field')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports accessibilityHint', () => {
|
||||||
|
const { getByPlaceholderText } = render(
|
||||||
|
<GlassInput
|
||||||
|
placeholder="Hint input"
|
||||||
|
accessibilityHint="Enter your username"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 = () => <Text>Icon</Text>;
|
||||||
|
|
||||||
|
const { getByText, getByPlaceholderText } = render(
|
||||||
|
<GlassInput
|
||||||
|
label="Full Props Input"
|
||||||
|
placeholder="Enter value"
|
||||||
|
error="Validation error"
|
||||||
|
icon={<TestIcon />}
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
<LoadingOverlay visible={false} statusText="Loading..." />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(queryByText('Loading...')).toBeNull();
|
||||||
|
expect(toJSON()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders when visible', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<LoadingOverlay visible={true} statusText="Loading..." />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Loading...')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('spinner variant (default)', () => {
|
||||||
|
it('renders spinner by default', () => {
|
||||||
|
const { UNSAFE_getByType } = render(
|
||||||
|
<LoadingOverlay visible={true} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const ActivityIndicator = require('react-native').ActivityIndicator;
|
||||||
|
expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders status text', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<LoadingOverlay visible={true} statusText="Please wait..." />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Please wait...')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders detail text', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<LoadingOverlay visible={true} detailText="This may take a moment" />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('This may take a moment')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders both status and detail text', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={true}
|
||||||
|
statusText="Uploading..."
|
||||||
|
detailText="50% complete"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Uploading...')).toBeTruthy();
|
||||||
|
expect(getByText('50% complete')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shimmer variant', () => {
|
||||||
|
it('renders shimmer variant', () => {
|
||||||
|
const { UNSAFE_getByType, getByText } = render(
|
||||||
|
<LoadingOverlay visible={true} variant="shimmer" statusText="Loading..." />
|
||||||
|
);
|
||||||
|
|
||||||
|
const ActivityIndicator = require('react-native').ActivityIndicator;
|
||||||
|
expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
|
||||||
|
expect(getByText('Loading...')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders shimmer with detail text', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={true}
|
||||||
|
variant="shimmer"
|
||||||
|
statusText="Syncing"
|
||||||
|
detailText="Almost done"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Syncing')).toBeTruthy();
|
||||||
|
expect(getByText('Almost done')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('progress variant', () => {
|
||||||
|
it('renders progress variant', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={true}
|
||||||
|
variant="progress"
|
||||||
|
progress={50}
|
||||||
|
statusText="Uploading..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Uploading...')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders progress with detail text', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={true}
|
||||||
|
variant="progress"
|
||||||
|
progress={75}
|
||||||
|
statusText="Processing"
|
||||||
|
detailText="3 of 4 complete"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<LoadingOverlay visible={true} variant="progress" progress={150} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should render without error
|
||||||
|
expect(toJSON()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles negative progress', () => {
|
||||||
|
const { toJSON } = render(
|
||||||
|
<LoadingOverlay visible={true} variant="progress" progress={-10} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should render without error
|
||||||
|
expect(toJSON()).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fullScreen mode', () => {
|
||||||
|
it('renders in fullScreen mode', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={true}
|
||||||
|
fullScreen={true}
|
||||||
|
statusText="Full screen loading"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Full screen loading')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders fullScreen with different variants', () => {
|
||||||
|
const { rerender, getByText } = render(
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={true}
|
||||||
|
fullScreen={true}
|
||||||
|
variant="spinner"
|
||||||
|
statusText="Spinner"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(getByText('Spinner')).toBeTruthy();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={true}
|
||||||
|
fullScreen={true}
|
||||||
|
variant="progress"
|
||||||
|
statusText="Progress"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(getByText('Progress')).toBeTruthy();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={true}
|
||||||
|
fullScreen={true}
|
||||||
|
variant="shimmer"
|
||||||
|
statusText="Shimmer"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(getByText('Shimmer')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('custom styling', () => {
|
||||||
|
it('applies custom style', () => {
|
||||||
|
const customStyle = { marginTop: 20 };
|
||||||
|
const { getByText } = render(
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={true}
|
||||||
|
style={customStyle}
|
||||||
|
statusText="Styled"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Styled')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom text style', () => {
|
||||||
|
const customTextStyle = { fontSize: 20 };
|
||||||
|
const { getByText } = render(
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={true}
|
||||||
|
textStyle={customTextStyle}
|
||||||
|
statusText="Custom text"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Custom text')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom spinner color', () => {
|
||||||
|
const { UNSAFE_getByType } = render(
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={true}
|
||||||
|
spinnerColor="#FF0000"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<LoadingOverlay visible={true} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const ActivityIndicator = require('react-native').ActivityIndicator;
|
||||||
|
expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has fullScreen false by default', () => {
|
||||||
|
const { toJSON } = render(
|
||||||
|
<LoadingOverlay visible={true} statusText="Test" />
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should render in non-fullscreen mode
|
||||||
|
expect(toJSON()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has progress 0 by default', () => {
|
||||||
|
const { toJSON } = render(
|
||||||
|
<LoadingOverlay visible={true} variant="progress" />
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should render without error
|
||||||
|
expect(toJSON()).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
<ShimmerEffect>
|
||||||
|
<Text>Child Content</Text>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Child Content')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders multiple children', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<ShimmerEffect>
|
||||||
|
<Text>First Child</Text>
|
||||||
|
<Text>Second Child</Text>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('First Child')).toBeTruthy();
|
||||||
|
expect(getByText('Second Child')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nested components', () => {
|
||||||
|
const { getByText, getByTestId } = render(
|
||||||
|
<ShimmerEffect>
|
||||||
|
<View testID="nested-view">
|
||||||
|
<Text>Nested Content</Text>
|
||||||
|
</View>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('nested-view')).toBeTruthy();
|
||||||
|
expect(getByText('Nested Content')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enabled prop', () => {
|
||||||
|
it('shows glow effect when enabled (default)', () => {
|
||||||
|
const { toJSON } = render(
|
||||||
|
<ShimmerEffect>
|
||||||
|
<Text>Content</Text>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should render with glow elements
|
||||||
|
expect(toJSON()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides glow effect when disabled', () => {
|
||||||
|
const { getByText, toJSON } = render(
|
||||||
|
<ShimmerEffect enabled={false}>
|
||||||
|
<Text>Content</Text>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Content')).toBeTruthy();
|
||||||
|
// The glow container should not be rendered when disabled
|
||||||
|
expect(toJSON()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles enabled state', () => {
|
||||||
|
const { rerender, getByText } = render(
|
||||||
|
<ShimmerEffect enabled={true}>
|
||||||
|
<Text>Content</Text>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
expect(getByText('Content')).toBeTruthy();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ShimmerEffect enabled={false}>
|
||||||
|
<Text>Content</Text>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
expect(getByText('Content')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('custom styling', () => {
|
||||||
|
it('applies custom style', () => {
|
||||||
|
const customStyle = { margin: 10, padding: 20 };
|
||||||
|
const { getByText } = render(
|
||||||
|
<ShimmerEffect style={customStyle}>
|
||||||
|
<Text>Styled Content</Text>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Styled Content')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom shimmer color', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<ShimmerEffect shimmerColor="#FF0000">
|
||||||
|
<Text>Red Shimmer</Text>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Red Shimmer')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('duration prop', () => {
|
||||||
|
it('accepts custom duration', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<ShimmerEffect duration={2000}>
|
||||||
|
<Text>Fast Shimmer</Text>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Fast Shimmer')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default duration of 4000ms', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<ShimmerEffect>
|
||||||
|
<Text>Default Duration</Text>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Default Duration')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('default props', () => {
|
||||||
|
it('has enabled true by default', () => {
|
||||||
|
const { toJSON, getByText } = render(
|
||||||
|
<ShimmerEffect>
|
||||||
|
<Text>Content</Text>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Content')).toBeTruthy();
|
||||||
|
expect(toJSON()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default shimmer color', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<ShimmerEffect>
|
||||||
|
<Text>Default Color</Text>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Default Color')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('composition', () => {
|
||||||
|
it('works with complex children', () => {
|
||||||
|
const { getByText, getByTestId } = render(
|
||||||
|
<ShimmerEffect>
|
||||||
|
<View testID="outer">
|
||||||
|
<Text>Header</Text>
|
||||||
|
<View testID="inner">
|
||||||
|
<Text>Body</Text>
|
||||||
|
</View>
|
||||||
|
<Text>Footer</Text>
|
||||||
|
</View>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ShimmerEffect shimmerColor="#FF0000">
|
||||||
|
<ShimmerEffect shimmerColor="#00FF00" enabled={false}>
|
||||||
|
<Text>Nested Shimmer</Text>
|
||||||
|
</ShimmerEffect>
|
||||||
|
</ShimmerEffect>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Nested Shimmer')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,91 +6,355 @@ import { useMediaPicker } from '@/hooks/useMediaPicker';
|
|||||||
describe('useMediaPicker', () => {
|
describe('useMediaPicker', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
jest.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when user cancels library picker', async () => {
|
afterEach(() => {
|
||||||
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
|
jest.useRealTimers();
|
||||||
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 () => {
|
describe('pickMedia', () => {
|
||||||
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
|
it('returns null when user cancels library picker', async () => {
|
||||||
canceled: false,
|
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
|
||||||
assets: [{
|
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',
|
uri: 'file://image.jpg',
|
||||||
type: 'image',
|
mediaType: 'image',
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 200,
|
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;
|
const { result } = renderHook(() => useMediaPicker());
|
||||||
await act(async () => {
|
|
||||||
picked = await result.current.pickMedia();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(picked).toEqual({
|
let picked: any;
|
||||||
uri: 'file://image.jpg',
|
await act(async () => {
|
||||||
mediaType: 'image',
|
picked = await result.current.pickMedia();
|
||||||
width: 100,
|
});
|
||||||
height: 200,
|
|
||||||
duration: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps selected video to MediaResult', async () => {
|
expect(picked).toEqual({
|
||||||
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
|
|
||||||
canceled: false,
|
|
||||||
assets: [{
|
|
||||||
uri: 'file://video.mp4',
|
uri: 'file://video.mp4',
|
||||||
type: 'video',
|
mediaType: 'video',
|
||||||
width: 640,
|
width: 640,
|
||||||
height: 480,
|
height: 480,
|
||||||
duration: 1234,
|
duration: 1234,
|
||||||
}],
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useMediaPicker());
|
it('uses custom config options', async () => {
|
||||||
|
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
|
||||||
|
canceled: true,
|
||||||
|
});
|
||||||
|
|
||||||
let picked: any;
|
const { result } = renderHook(() => useMediaPicker({
|
||||||
await act(async () => {
|
imageQuality: 0.5,
|
||||||
picked = await result.current.pickMedia();
|
libraryVideoMaxDuration: 120,
|
||||||
});
|
}));
|
||||||
|
|
||||||
expect(picked).toEqual({
|
await act(async () => {
|
||||||
uri: 'file://video.mp4',
|
await result.current.pickMedia();
|
||||||
mediaType: 'video',
|
});
|
||||||
width: 640,
|
|
||||||
height: 480,
|
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({
|
||||||
duration: 1234,
|
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||||
|
quality: 0.5,
|
||||||
|
videoMaxDuration: 120,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('alerts and returns false when camera permission denied', async () => {
|
describe('requestCameraPermission', () => {
|
||||||
const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
|
it('returns true when permission granted', async () => {
|
||||||
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
|
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
|
||||||
|
|
||||||
const { result } = renderHook(() => useMediaPicker());
|
const { result } = renderHook(() => useMediaPicker());
|
||||||
|
|
||||||
let ok: any;
|
let ok: any;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
ok = await result.current.requestCameraPermission();
|
ok = await result.current.requestCameraPermission();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(ok).toBe(false);
|
it('alerts and returns false when camera permission denied', async () => {
|
||||||
expect(alertSpy).toHaveBeenCalled();
|
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 })
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,9 @@ function createThenableQuery(result: any) {
|
|||||||
in: jest.fn(() => query),
|
in: jest.fn(() => query),
|
||||||
order: jest.fn(() => query),
|
order: jest.fn(() => query),
|
||||||
range: 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),
|
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
|
||||||
};
|
};
|
||||||
return query;
|
return query;
|
||||||
@@ -19,31 +22,464 @@ function createThenableQuery(result: any) {
|
|||||||
describe('matchStore', () => {
|
describe('matchStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
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<string>(),
|
||||||
|
showArchived: false,
|
||||||
|
isLoadingArchived: false,
|
||||||
|
});
|
||||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
|
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sorts matches with unread messages first', async () => {
|
describe('fetchMatches', () => {
|
||||||
const matches = [
|
it('sorts matches with unread messages first', async () => {
|
||||||
{ id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: true },
|
const matches = [
|
||||||
{ id: 'b', question_id: 'q2', created_at: '2024-01-03T00:00:00.000Z', is_new: false },
|
{ 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 archivesQuery = createThenableQuery({ data: [] });
|
||||||
const responsesQuery = createThenableQuery({ data: [] });
|
const countQuery = createThenableQuery({ count: 2 });
|
||||||
const unreadQuery = createThenableQuery({ data: unreadMessages, error: null });
|
const matchesQuery = createThenableQuery({ data: matches });
|
||||||
|
const responsesQuery = createThenableQuery({ data: [] });
|
||||||
|
const unreadQuery = createThenableQuery({ data: unreadMessages, error: null });
|
||||||
|
|
||||||
(supabase.from as jest.Mock)
|
(supabase.from as jest.Mock)
|
||||||
.mockReturnValueOnce(matchesQuery) // matches
|
.mockReturnValueOnce(archivesQuery) // archived matches
|
||||||
.mockReturnValueOnce(responsesQuery) // responses
|
.mockReturnValueOnce(countQuery) // count
|
||||||
.mockReturnValueOnce(unreadQuery); // messages
|
.mockReturnValueOnce(matchesQuery) // matches
|
||||||
|
.mockReturnValueOnce(responsesQuery) // responses
|
||||||
|
.mockReturnValueOnce(unreadQuery); // messages
|
||||||
|
|
||||||
await useMatchStore.getState().fetchMatches();
|
await useMatchStore.getState().fetchMatches(true);
|
||||||
|
|
||||||
const state = useMatchStore.getState();
|
const state = useMatchStore.getState();
|
||||||
expect(state.matches[0].id).toBe('a');
|
expect(state.matches[0].id).toBe('a');
|
||||||
expect(state.newMatchesCount).toBe(1);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
jest.mock('../../lib/revenuecat', () => {
|
jest.mock('../../lib/revenuecat', () => ({
|
||||||
const service = {
|
__esModule: true,
|
||||||
|
default: {
|
||||||
isAvailable: jest.fn(() => true),
|
isAvailable: jest.fn(() => true),
|
||||||
isInitialized: jest.fn(() => true),
|
isInitialized: jest.fn(() => true),
|
||||||
initialize: jest.fn(async () => {}),
|
initialize: jest.fn(async () => {}),
|
||||||
login: jest.fn(async () => null),
|
login: jest.fn(async () => null),
|
||||||
logout: jest.fn(async () => {}),
|
logout: jest.fn(async () => {}),
|
||||||
addCustomerInfoUpdateListener: jest.fn(() => null),
|
addCustomerInfoUpdateListener: jest.fn(() => () => {}),
|
||||||
getOfferingsDebug: jest.fn(async () => ({
|
getOfferingsDebug: jest.fn(async () => ({
|
||||||
current: { identifier: 'default', availablePackages: [{ identifier: 'monthly', packageType: 'MONTHLY', product: { price: 1, priceString: '$1', currencyCode: 'USD' } }] },
|
current: { identifier: 'default', availablePackages: [{ identifier: 'monthly', packageType: 'MONTHLY', product: { price: 1, priceString: '$1', currencyCode: 'USD' } }] },
|
||||||
availableOfferings: ['default'],
|
availableOfferings: ['default'],
|
||||||
@@ -15,24 +16,33 @@ jest.mock('../../lib/revenuecat', () => {
|
|||||||
restorePurchases: jest.fn(async () => ({ entitlements: { active: {} } })),
|
restorePurchases: jest.fn(async () => ({ entitlements: { active: {} } })),
|
||||||
getCustomerInfo: jest.fn(async () => ({ entitlements: { active: {} } })),
|
getCustomerInfo: jest.fn(async () => ({ entitlements: { active: {} } })),
|
||||||
parseCustomerInfo: jest.fn(() => ({ isProUser: false, activeSubscription: null, expirationDate: null, willRenew: false })),
|
parseCustomerInfo: jest.fn(() => ({ isProUser: false, activeSubscription: null, expirationDate: null, willRenew: false })),
|
||||||
};
|
},
|
||||||
|
get revenueCatService() {
|
||||||
return {
|
return this.default;
|
||||||
__esModule: true,
|
},
|
||||||
default: service,
|
SubscriptionState: {},
|
||||||
revenueCatService: service,
|
PurchasesPackage: {},
|
||||||
SubscriptionState: {},
|
PurchasesOffering: {},
|
||||||
PurchasesPackage: {},
|
}));
|
||||||
PurchasesOffering: {},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import { useSubscriptionStore } from '@/store/subscriptionStore';
|
import { useSubscriptionStore } from '@/store/subscriptionStore';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import revenueCatService from '../../lib/revenuecat';
|
||||||
|
|
||||||
describe('subscriptionStore', () => {
|
describe('subscriptionStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
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({
|
useSubscriptionStore.setState({
|
||||||
subscription: { isProUser: false, activeSubscription: null, expirationDate: null, willRenew: false },
|
subscription: { isProUser: false, activeSubscription: null, expirationDate: null, willRenew: false },
|
||||||
offerings: null,
|
offerings: null,
|
||||||
@@ -40,24 +50,236 @@ describe('subscriptionStore', () => {
|
|||||||
isPurchasing: false,
|
isPurchasing: false,
|
||||||
error: null,
|
error: null,
|
||||||
} as any);
|
} 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 () => {
|
describe('initializeRevenueCat', () => {
|
||||||
await useSubscriptionStore.getState().fetchOfferings();
|
it('skips initialization if RevenueCat not available', async () => {
|
||||||
|
(revenueCatService.isAvailable as jest.Mock).mockReturnValue(false);
|
||||||
|
|
||||||
const state = useSubscriptionStore.getState();
|
await useSubscriptionStore.getState().initializeRevenueCat('user1');
|
||||||
expect(state.offerings).toBeTruthy();
|
|
||||||
expect(state.isLoadingOfferings).toBe(false);
|
expect(revenueCatService.initialize).not.toHaveBeenCalled();
|
||||||
expect(state.error).toBeNull();
|
});
|
||||||
|
|
||||||
|
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', () => {
|
describe('fetchOfferings', () => {
|
||||||
useSubscriptionStore.setState({ offerings: { identifier: 'x', availablePackages: [] } } as any);
|
it('stores offering on success', async () => {
|
||||||
useSubscriptionStore.getState().clearSubscription();
|
await useSubscriptionStore.getState().fetchOfferings();
|
||||||
|
|
||||||
const state = useSubscriptionStore.getState();
|
const state = useSubscriptionStore.getState();
|
||||||
expect(state.offerings).toBeNull();
|
expect(state.offerings).toBeTruthy();
|
||||||
expect(state.subscription.isProUser).toBe(false);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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 (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.responseBadgeCompact,
|
||||||
|
{ backgroundColor: style.bg, borderColor: style.border },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name={style.icon} size={14} color={style.color} />
|
||||||
|
<Text style={[styles.responseBadgeTextCompact, { color: style.color }]}>
|
||||||
|
{style.text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MatchNotificationModal: React.FC<Props> = ({
|
||||||
|
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 (
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.overlay, { opacity: overlayOpacity }]}
|
||||||
|
>
|
||||||
|
{/* Background press to dismiss */}
|
||||||
|
<Pressable style={StyleSheet.absoluteFill} onPress={handleDismiss} />
|
||||||
|
|
||||||
|
{/* Modal card with glow */}
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.glowContainer,
|
||||||
|
{
|
||||||
|
transform: [{ scale: modalScale }],
|
||||||
|
opacity: modalOpacity,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Outer glow effect */}
|
||||||
|
<View style={styles.glowOuter} />
|
||||||
|
|
||||||
|
<View style={styles.modalContainer}>
|
||||||
|
{/* Glass background */}
|
||||||
|
<BlurView intensity={40} tint="dark" style={styles.blurContainer}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
|
||||||
|
style={styles.gradientOverlay}
|
||||||
|
/>
|
||||||
|
</BlurView>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* Title section */}
|
||||||
|
<Text style={styles.title}>It's a Match!</Text>
|
||||||
|
|
||||||
|
{/* Match type badge */}
|
||||||
|
<View style={[styles.matchTypeBadge, { borderColor: `${matchTypeInfo.color}30` }]}>
|
||||||
|
<Text style={[styles.matchTypeText, { color: matchTypeInfo.color }]}>
|
||||||
|
{matchTypeInfo.text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<DecorativeSeparator variant="muted" width={80} marginVertical={spacing.md} />
|
||||||
|
|
||||||
|
{/* Question text and responses */}
|
||||||
|
{match.question.partner_text ? (
|
||||||
|
// Two-part question - show what each person saw
|
||||||
|
<View style={styles.twoPartContainer}>
|
||||||
|
<View style={styles.responseSection}>
|
||||||
|
<View style={styles.responseLabelRow}>
|
||||||
|
<Text style={styles.responsePersonLabel}>You said</Text>
|
||||||
|
<ResponseBadge response={userResponse} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.questionTextSmall}>"{match.question.text}"</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.matchConnector}>
|
||||||
|
<View style={styles.connectorLine} />
|
||||||
|
<View style={styles.connectorHeart}>
|
||||||
|
<Ionicons name="heart" size={16} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.connectorLine} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.responseSection}>
|
||||||
|
<View style={styles.responseLabelRow}>
|
||||||
|
<Text style={styles.responsePersonLabel}>{partnerDisplayName} said</Text>
|
||||||
|
<ResponseBadge response={partnerResponse} isPartner />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.questionTextSmall}>"{match.question.partner_text}"</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
// Single question - show shared text with inline responses
|
||||||
|
<View style={styles.questionContainer}>
|
||||||
|
<Text style={styles.questionText}>"{match.question.text}"</Text>
|
||||||
|
<View style={styles.responsesInline}>
|
||||||
|
<Text style={styles.responseInlineText}>You said </Text>
|
||||||
|
<Text style={[styles.responseInlineAnswer, { color: userResponse === 'yes' ? colors.primary : colors.warning }]}>
|
||||||
|
{userResponse.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.responseInlineText}> · {partnerDisplayName} said </Text>
|
||||||
|
<Text style={[styles.responseInlineAnswer, { color: partnerResponse === 'yes' ? colors.success : colors.warning }]}>
|
||||||
|
{partnerResponse.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DecorativeSeparator variant="muted" width={100} marginVertical={spacing.md} />
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
<Pressable onPress={handleChat} style={styles.chatButton}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.primary as [string, string]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.chatButtonGradient}
|
||||||
|
>
|
||||||
|
<Ionicons name="chatbubble" size={16} color={colors.text} />
|
||||||
|
<Text style={styles.chatButtonText}>Chat</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable onPress={handleDismiss} style={styles.dismissButton}>
|
||||||
|
<Text style={styles.dismissButtonText}>Continue</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Border */}
|
||||||
|
<View style={styles.border} pointerEvents="none" />
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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)',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Archive action revealed on swipe */}
|
||||||
|
<Animated.View style={[styles.actionContainer, actionStyle]}>
|
||||||
|
<Animated.View style={[styles.actionContent, actionIconStyle]}>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<Ionicons
|
||||||
|
name={isArchived ? "arrow-undo" : "archive"}
|
||||||
|
size={22}
|
||||||
|
color={ACCENT}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.actionText}>
|
||||||
|
{isArchived ? "Restore" : "Archive"}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<GestureDetector gesture={panGesture}>
|
||||||
|
<Animated.View style={containerStyle}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SwipeableMatchItem } from './SwipeableMatchItem';
|
||||||
@@ -109,12 +109,6 @@ export function ResponseCard({ response, index, onEditPress, onChatPress }: Resp
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Partner status indicator */}
|
|
||||||
{response.partner_answered && (
|
|
||||||
<View style={styles.partnerBadge}>
|
|
||||||
<Ionicons name="person" size={10} color={colors.textTertiary} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -215,14 +209,6 @@ const styles = StyleSheet.create({
|
|||||||
...typography.caption2,
|
...typography.caption2,
|
||||||
color: colors.textTertiary,
|
color: colors.textTertiary,
|
||||||
},
|
},
|
||||||
partnerBadge: {
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
borderRadius: 9,
|
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
rightSection: {
|
rightSection: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Modal, Dimensions, Alert } from "react-native";
|
import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native";
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { Video, ResizeMode } from "expo-av";
|
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
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 { useMediaUpload } from "./hooks/useMediaUpload";
|
||||||
import { useMessageActions } from "./hooks/useMessageActions";
|
import { useMessageActions } from "./hooks/useMessageActions";
|
||||||
import type { ReportReason } from "./types";
|
import type { ReportReason, Match } from "./types";
|
||||||
|
|
||||||
import { supabase } from "../../lib/supabase";
|
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 { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useMediaSaver } from "../../hooks";
|
||||||
import { Database } from "../../types/supabase";
|
import { Database } from "../../types/supabase";
|
||||||
import { GradientBackground } from "../../components/ui";
|
import { GradientBackground } from "../../components/ui";
|
||||||
@@ -25,7 +22,6 @@ import { Events } from "../../lib/analytics";
|
|||||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||||
|
|
||||||
// Premium color palette for Chat
|
// Premium color palette for Chat
|
||||||
const ACCENT = colors.premium.gold;
|
|
||||||
const ROSE_RGBA = 'rgba(232, 164, 174, ';
|
const ROSE_RGBA = 'rgba(232, 164, 174, ';
|
||||||
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
|
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
|
||||||
|
|
||||||
@@ -36,8 +32,10 @@ export const ChatScreen: React.FC = () => {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { user, partner } = useAuthStore();
|
const { user, partner } = useAuthStore();
|
||||||
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
|
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
|
||||||
const [match, setMatch] = useState<any>(null);
|
const { archiveMatch, unarchiveMatch, isMatchArchived } = useMatchStore();
|
||||||
|
const [match, setMatch] = useState<Match | null>(null);
|
||||||
const [inputText, setInputText] = useState("");
|
const [inputText, setInputText] = useState("");
|
||||||
|
const [showSettingsSheet, setShowSettingsSheet] = useState(false);
|
||||||
const { uploading, uploadStatus, uploadMedia } = useMediaUpload(matchId, user?.id);
|
const { uploading, uploadStatus, uploadMedia } = useMediaUpload(matchId, user?.id);
|
||||||
const { saveMedia, saving: mediaSaving } = useMediaSaver();
|
const { saveMedia, saving: mediaSaving } = useMediaSaver();
|
||||||
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
|
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
|
||||||
@@ -81,6 +79,62 @@ export const ChatScreen: React.FC = () => {
|
|||||||
onReport: (message) => setReportingMessage(message),
|
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
|
// Handle long press on message to show options
|
||||||
const handleMessageLongPress = useCallback((message: Message, isMe: boolean) => {
|
const handleMessageLongPress = useCallback((message: Message, isMe: boolean) => {
|
||||||
// Don't show options for already deleted messages
|
// Don't show options for already deleted messages
|
||||||
@@ -89,7 +143,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
}, [showDeleteOptions]);
|
}, [showDeleteOptions]);
|
||||||
|
|
||||||
// Handle report submission
|
// Handle report submission
|
||||||
const handleSubmitReport = async (reason: ReportReason) => {
|
const handleSubmitReport = useCallback(async (reason: ReportReason) => {
|
||||||
if (!reportingMessage || !user?.id) return;
|
if (!reportingMessage || !user?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -127,7 +181,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
Alert.alert('Error', 'Failed to submit report');
|
Alert.alert('Error', 'Failed to submit report');
|
||||||
setReportingMessage(null);
|
setReportingMessage(null);
|
||||||
}
|
}
|
||||||
};
|
}, [reportingMessage, user?.id, deleteForSelf, setMessages]);
|
||||||
|
|
||||||
// Track active chat to prevent notifications for current chat
|
// Track active chat to prevent notifications for current chat
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
@@ -149,6 +203,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
if (!matchId) return;
|
if (!matchId) return;
|
||||||
|
|
||||||
const fetchMatch = async () => {
|
const fetchMatch = async () => {
|
||||||
|
setMatch(null); // Clear previous match data immediately
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from("matches")
|
.from("matches")
|
||||||
.select("*, question:questions(*)")
|
.select("*, question:questions(*)")
|
||||||
@@ -168,15 +223,15 @@ export const ChatScreen: React.FC = () => {
|
|||||||
fetchMatch();
|
fetchMatch();
|
||||||
}, [matchId]);
|
}, [matchId]);
|
||||||
|
|
||||||
const handleTyping = (text: string) => {
|
const handleTyping = useCallback((text: string) => {
|
||||||
setInputText(text);
|
setInputText(text);
|
||||||
|
|
||||||
if (text.length > 0) {
|
if (text.length > 0) {
|
||||||
sendTypingEvent();
|
sendTypingEvent();
|
||||||
}
|
}
|
||||||
};
|
}, [sendTypingEvent]);
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (!inputText.trim()) return;
|
if (!inputText.trim()) return;
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
@@ -203,30 +258,30 @@ export const ChatScreen: React.FC = () => {
|
|||||||
Alert.alert("Delivery Failed", "Couldn't send message. Please check your connection.");
|
Alert.alert("Delivery Failed", "Couldn't send message. Please check your connection.");
|
||||||
setInputText(content);
|
setInputText(content);
|
||||||
}
|
}
|
||||||
};
|
}, [inputText, user, matchId]);
|
||||||
|
|
||||||
const handlePickMedia = async () => {
|
const handlePickMedia = useCallback(async () => {
|
||||||
const result = await pickMedia();
|
const result = await pickMedia();
|
||||||
if (result) {
|
if (result) {
|
||||||
uploadMedia(result.uri, result.mediaType);
|
uploadMedia(result.uri, result.mediaType);
|
||||||
}
|
}
|
||||||
};
|
}, [pickMedia, uploadMedia]);
|
||||||
|
|
||||||
const handleTakePhoto = async () => {
|
const handleTakePhoto = useCallback(async () => {
|
||||||
const result = await takePhoto();
|
const result = await takePhoto();
|
||||||
if (result) {
|
if (result) {
|
||||||
uploadMedia(result.uri, 'image');
|
uploadMedia(result.uri, 'image');
|
||||||
}
|
}
|
||||||
};
|
}, [takePhoto, uploadMedia]);
|
||||||
|
|
||||||
const handleRecordVideo = async () => {
|
const handleRecordVideo = useCallback(async () => {
|
||||||
const result = await recordVideo();
|
const result = await recordVideo();
|
||||||
if (result) {
|
if (result) {
|
||||||
uploadMedia(result.uri, 'video');
|
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 message = messages.find(m => m.id === messageId);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// Set expiration 30 days from now for videos only
|
// Set expiration 30 days from now for videos only
|
||||||
@@ -249,7 +304,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
media_expires_at: expiresAt
|
media_expires_at: expiresAt
|
||||||
})
|
})
|
||||||
.eq("id", messageId);
|
.eq("id", messageId);
|
||||||
};
|
}, [messages, setMessages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
@@ -279,10 +334,9 @@ export const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
partner={partner}
|
partner={partner}
|
||||||
user={user}
|
|
||||||
match={match}
|
|
||||||
insets={insets}
|
insets={insets}
|
||||||
onBack={() => router.push('/(app)/matches')}
|
onBack={handleBack}
|
||||||
|
onSettingsPress={handleOpenSettings}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
@@ -326,124 +380,41 @@ export const ChatScreen: React.FC = () => {
|
|||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
{/* Full Screen Image Modal */}
|
{/* Full Screen Image Modal */}
|
||||||
<Modal
|
<FullScreenImageModal
|
||||||
|
uri={fullScreenImage}
|
||||||
visible={!!fullScreenImage}
|
visible={!!fullScreenImage}
|
||||||
transparent
|
loading={fullScreenImageLoading}
|
||||||
animationType="fade"
|
saving={mediaSaving}
|
||||||
onRequestClose={() => setFullScreenImage(null)}
|
onClose={handleCloseImage}
|
||||||
>
|
onSave={handleSaveImage}
|
||||||
<View style={styles.fullScreenOverlay}>
|
onLoadStart={handleImageLoadStart}
|
||||||
<TouchableOpacity
|
onLoadEnd={handleImageLoadEnd}
|
||||||
style={styles.fullScreenSaveButton}
|
/>
|
||||||
onPress={() => fullScreenImage && saveMedia(fullScreenImage, 'image')}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
disabled={mediaSaving}
|
|
||||||
>
|
|
||||||
<LinearGradient
|
|
||||||
colors={gradients.premiumGold as [string, string]}
|
|
||||||
style={styles.fullScreenCloseGradient}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
>
|
|
||||||
{mediaSaving ? (
|
|
||||||
<ActivityIndicator size="small" color={colors.text} />
|
|
||||||
) : (
|
|
||||||
<Ionicons name="download-outline" size={24} color={colors.text} />
|
|
||||||
)}
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.fullScreenCloseButton}
|
|
||||||
onPress={() => setFullScreenImage(null)}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<LinearGradient
|
|
||||||
colors={gradients.premiumGold as [string, string]}
|
|
||||||
style={styles.fullScreenCloseGradient}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color={colors.text} />
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{fullScreenImageLoading && (
|
|
||||||
<ActivityIndicator size="large" color={ACCENT} style={styles.fullScreenSpinner} />
|
|
||||||
)}
|
|
||||||
{fullScreenImage && (
|
|
||||||
<Image
|
|
||||||
source={{ uri: fullScreenImage }}
|
|
||||||
style={styles.fullScreenImage}
|
|
||||||
contentFit="contain"
|
|
||||||
cachePolicy="disk"
|
|
||||||
onLoadStart={() => setFullScreenImageLoading(true)}
|
|
||||||
onLoadEnd={() => setFullScreenImageLoading(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Full Screen Video Modal */}
|
{/* Full Screen Video Modal */}
|
||||||
<Modal
|
<FullScreenVideoModal
|
||||||
|
uri={fullScreenVideo}
|
||||||
visible={!!fullScreenVideo}
|
visible={!!fullScreenVideo}
|
||||||
transparent
|
saving={mediaSaving}
|
||||||
animationType="fade"
|
onClose={handleCloseVideo}
|
||||||
onRequestClose={() => setFullScreenVideo(null)}
|
onSave={handleSaveVideo}
|
||||||
>
|
/>
|
||||||
<View style={styles.fullScreenOverlay}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.fullScreenSaveButton}
|
|
||||||
onPress={() => fullScreenVideo && saveMedia(fullScreenVideo, 'video')}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
disabled={mediaSaving}
|
|
||||||
>
|
|
||||||
<LinearGradient
|
|
||||||
colors={gradients.premiumGold as [string, string]}
|
|
||||||
style={styles.fullScreenCloseGradient}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
>
|
|
||||||
{mediaSaving ? (
|
|
||||||
<ActivityIndicator size="small" color={colors.text} />
|
|
||||||
) : (
|
|
||||||
<Ionicons name="download-outline" size={24} color={colors.text} />
|
|
||||||
)}
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.fullScreenCloseButton}
|
|
||||||
onPress={() => setFullScreenVideo(null)}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<LinearGradient
|
|
||||||
colors={gradients.premiumGold as [string, string]}
|
|
||||||
style={styles.fullScreenCloseGradient}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color={colors.text} />
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{fullScreenVideo && (
|
|
||||||
<Video
|
|
||||||
source={{ uri: fullScreenVideo }}
|
|
||||||
style={styles.fullScreenVideo}
|
|
||||||
resizeMode={ResizeMode.CONTAIN}
|
|
||||||
useNativeControls
|
|
||||||
shouldPlay
|
|
||||||
isLooping={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Report Message Sheet */}
|
{/* Report Message Sheet */}
|
||||||
<ReportMessageSheet
|
<ReportMessageSheet
|
||||||
visible={reportingMessage !== null}
|
visible={reportingMessage !== null}
|
||||||
onClose={() => setReportingMessage(null)}
|
onClose={handleCloseReport}
|
||||||
onSubmit={handleSubmitReport}
|
onSubmit={handleSubmitReport}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Chat Settings Sheet */}
|
||||||
|
<ChatSettingsSheet
|
||||||
|
visible={showSettingsSheet}
|
||||||
|
onClose={handleCloseSettings}
|
||||||
|
isArchived={isMatchArchived(matchId)}
|
||||||
|
onArchive={handleArchive}
|
||||||
|
onUnarchive={handleUnarchive}
|
||||||
|
/>
|
||||||
</GradientBackground>
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -472,43 +443,6 @@ const styles = StyleSheet.create({
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: 140,
|
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: {
|
inputWrapper: {
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ const ACCENT_RGBA = 'rgba(212, 175, 55, ';
|
|||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
partner: Profile | null;
|
partner: Profile | null;
|
||||||
user: { id: string } | null;
|
|
||||||
match: any; // Kept for compatibility but unused
|
|
||||||
insets: EdgeInsets;
|
insets: EdgeInsets;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
onSettingsPress?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
const ChatHeaderComponent: React.FC<ChatHeaderProps> = ({
|
||||||
partner,
|
partner,
|
||||||
insets,
|
insets,
|
||||||
onBack,
|
onBack,
|
||||||
|
onSettingsPress,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -50,6 +50,8 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
|||||||
onPress={onBack}
|
onPress={onBack}
|
||||||
style={styles.backButton}
|
style={styles.backButton}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Go back to matches"
|
||||||
>
|
>
|
||||||
<View style={styles.backButtonInner}>
|
<View style={styles.backButtonInner}>
|
||||||
<Ionicons name="chevron-back" size={20} color={ACCENT} />
|
<Ionicons name="chevron-back" size={20} color={ACCENT} />
|
||||||
@@ -86,7 +88,21 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.headerSpacer} />
|
{onSettingsPress ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onSettingsPress}
|
||||||
|
style={styles.settingsButton}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Chat settings"
|
||||||
|
>
|
||||||
|
<View style={styles.backButtonInner}>
|
||||||
|
<Ionicons name="ellipsis-horizontal" size={20} color={ACCENT} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<View style={styles.headerSpacer} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bottom border */}
|
{/* Bottom border */}
|
||||||
<View style={styles.headerBorderBottom} />
|
<View style={styles.headerBorderBottom} />
|
||||||
@@ -94,6 +110,9 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wrap with React.memo for performance
|
||||||
|
export const ChatHeader = React.memo(ChatHeaderComponent);
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -179,6 +198,12 @@ const styles = StyleSheet.create({
|
|||||||
headerSpacer: {
|
headerSpacer: {
|
||||||
width: 40,
|
width: 40,
|
||||||
},
|
},
|
||||||
|
settingsButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
headerBorderBottom: {
|
headerBorderBottom: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
|||||||
@@ -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 { View, Text, FlatList, StyleSheet } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import Animated, { FadeIn } from 'react-native-reanimated';
|
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||||
@@ -12,14 +12,14 @@ import { MatchCard } from './MatchCard';
|
|||||||
import { DecorativeSeparator } from '../../../components/ui';
|
import { DecorativeSeparator } from '../../../components/ui';
|
||||||
import { colors, spacing, typography } from '../../../theme';
|
import { colors, spacing, typography } from '../../../theme';
|
||||||
import { Database } from '../../../types/supabase';
|
import { Database } from '../../../types/supabase';
|
||||||
import { UploadStatus } from '../types';
|
import { UploadStatus, Match } from '../types';
|
||||||
|
|
||||||
type Message = Database['public']['Tables']['messages']['Row'];
|
type Message = Database['public']['Tables']['messages']['Row'];
|
||||||
|
|
||||||
interface ChatMessagesProps {
|
interface ChatMessagesProps {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
userId: string | undefined;
|
userId: string | undefined;
|
||||||
match: any;
|
match: Match | null;
|
||||||
uploadStatus: UploadStatus | null;
|
uploadStatus: UploadStatus | null;
|
||||||
partnerTyping: boolean;
|
partnerTyping: boolean;
|
||||||
onImagePress: (uri: string) => void;
|
onImagePress: (uri: string) => void;
|
||||||
@@ -43,7 +43,7 @@ function isSameDay(date1: Date, date2: Date): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatMessages: React.FC<ChatMessagesProps> = ({
|
const ChatMessagesComponent: React.FC<ChatMessagesProps> = ({
|
||||||
messages,
|
messages,
|
||||||
userId,
|
userId,
|
||||||
match,
|
match,
|
||||||
@@ -56,7 +56,14 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(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 isMe = item.user_id === userId;
|
||||||
const messageDate = new Date(item.created_at!);
|
const messageDate = new Date(item.created_at!);
|
||||||
|
|
||||||
@@ -70,7 +77,7 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<View>
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
isMe={isMe}
|
isMe={isMe}
|
||||||
index={index}
|
index={index}
|
||||||
@@ -86,58 +93,68 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
|
|||||||
/>
|
/>
|
||||||
</MessageBubble>
|
</MessageBubble>
|
||||||
{showDateSeparator && <DateSeparator date={messageDate} />}
|
{showDateSeparator && <DateSeparator date={messageDate} />}
|
||||||
</>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
}, [messages, userId, onMessageLongPress, revealMessage, onImagePress, onVideoFullScreen]);
|
||||||
|
|
||||||
|
// Memoize ListHeaderComponent to prevent recreation
|
||||||
|
const listHeaderComponent = useMemo(() => (
|
||||||
|
<View>
|
||||||
|
{uploadStatus && <UploadProgress uploadStatus={uploadStatus} />}
|
||||||
|
{partnerTyping && <TypingIndicator />}
|
||||||
|
</View>
|
||||||
|
), [uploadStatus, partnerTyping]);
|
||||||
|
|
||||||
|
// Memoize ListFooterComponent to prevent recreation
|
||||||
|
const listFooterComponent = useMemo(() => (
|
||||||
|
<View style={styles.footerContainer}>
|
||||||
|
<MatchCard match={match} user={userObject} />
|
||||||
|
</View>
|
||||||
|
), [match, userObject]);
|
||||||
|
|
||||||
|
// Memoize ListEmptyComponent to prevent recreation
|
||||||
|
const listEmptyComponent = useMemo(() => (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.delay(200).duration(400)}
|
||||||
|
style={styles.emptyChat}
|
||||||
|
>
|
||||||
|
<View style={styles.emptyIconContainer}>
|
||||||
|
<Ionicons name="chatbubbles-outline" size={32} color={ACCENT} />
|
||||||
|
</View>
|
||||||
|
<DecorativeSeparator variant="gold" width={100} marginVertical={spacing.md} />
|
||||||
|
<Text style={styles.emptyChatTitle}>Start the Conversation</Text>
|
||||||
|
<Text style={styles.emptyChatSubtitle}>
|
||||||
|
Share your thoughts about this match
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={flatListRef}
|
ref={flatListRef}
|
||||||
data={messages}
|
data={messages}
|
||||||
renderItem={renderMessage}
|
renderItem={renderMessage}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={keyExtractor}
|
||||||
inverted
|
inverted
|
||||||
style={styles.messageList}
|
style={styles.messageList}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
ListHeaderComponent={
|
// Performance optimizations
|
||||||
<>
|
removeClippedSubviews={true}
|
||||||
{/* Show uploading status when media is being uploaded */}
|
maxToRenderPerBatch={15}
|
||||||
{uploadStatus && <UploadProgress uploadStatus={uploadStatus} />}
|
windowSize={10}
|
||||||
|
initialNumToRender={20}
|
||||||
{/* Show typing indicator when partner is typing */}
|
ListHeaderComponent={listHeaderComponent}
|
||||||
{partnerTyping && <TypingIndicator />}
|
ListFooterComponent={listFooterComponent}
|
||||||
</>
|
ListEmptyComponent={listEmptyComponent}
|
||||||
}
|
|
||||||
ListFooterComponent={
|
|
||||||
<View style={{ paddingBottom: spacing.md }}>
|
|
||||||
<MatchCard match={match} user={{ id: userId || '' }} />
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<Animated.View
|
|
||||||
entering={FadeIn.delay(200).duration(400)}
|
|
||||||
style={styles.emptyChat}
|
|
||||||
>
|
|
||||||
{/* Premium icon */}
|
|
||||||
<View style={styles.emptyIconContainer}>
|
|
||||||
<Ionicons name="chatbubbles-outline" size={32} color={ACCENT} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Decorative separator */}
|
|
||||||
<DecorativeSeparator variant="gold" width={100} marginVertical={spacing.md} />
|
|
||||||
|
|
||||||
<Text style={styles.emptyChatTitle}>Start the Conversation</Text>
|
|
||||||
<Text style={styles.emptyChatSubtitle}>
|
|
||||||
Share your thoughts about this match
|
|
||||||
</Text>
|
|
||||||
</Animated.View>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wrap with React.memo for performance
|
||||||
|
export const ChatMessages = React.memo(ChatMessagesComponent);
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
messageList: {
|
messageList: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -146,6 +163,9 @@ const styles = StyleSheet.create({
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
},
|
},
|
||||||
|
footerContainer: {
|
||||||
|
paddingBottom: spacing.md,
|
||||||
|
},
|
||||||
emptyChat: {
|
emptyChat: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
onUnarchive: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="none"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
statusBarTranslucent
|
||||||
|
>
|
||||||
|
<Animated.View style={styles.overlay} entering={FadeIn.duration(200)} exiting={FadeOut.duration(200)}>
|
||||||
|
<TouchableWithoutFeedback onPress={onClose}>
|
||||||
|
<View style={styles.backdrop} />
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={styles.sheetContainer}
|
||||||
|
entering={SlideInDown.springify().damping(20)}
|
||||||
|
exiting={SlideOutDown.duration(200)}
|
||||||
|
>
|
||||||
|
{Platform.OS === "ios" ? (
|
||||||
|
<BlurView intensity={80} tint="dark" style={styles.sheet}>
|
||||||
|
<SheetContent
|
||||||
|
isArchived={isArchived}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClose={onClose}
|
||||||
|
onArchiveToggle={handleArchiveToggle}
|
||||||
|
/>
|
||||||
|
</BlurView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.sheet, styles.sheetAndroid]}>
|
||||||
|
<SheetContent
|
||||||
|
isArchived={isArchived}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClose={onClose}
|
||||||
|
onArchiveToggle={handleArchiveToggle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
isArchived,
|
||||||
|
isLoading,
|
||||||
|
onClose,
|
||||||
|
onArchiveToggle,
|
||||||
|
}: {
|
||||||
|
isArchived: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onArchiveToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Handle */}
|
||||||
|
<View style={styles.handle} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>Chat Settings</Text>
|
||||||
|
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||||
|
<Ionicons name="close" size={20} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Archive option */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.optionButton}
|
||||||
|
onPress={onArchiveToggle}
|
||||||
|
disabled={isLoading}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.optionContent}>
|
||||||
|
<View style={styles.optionIconContainer}>
|
||||||
|
<Ionicons
|
||||||
|
name={isArchived ? "arrow-undo" : "archive"}
|
||||||
|
size={22}
|
||||||
|
color={ACCENT}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.optionTextContainer}>
|
||||||
|
<Text style={styles.optionTitle}>
|
||||||
|
{isArchived ? "Unarchive Chat" : "Archive Chat"}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.optionDescription}>
|
||||||
|
{isArchived
|
||||||
|
? "Move this chat back to your main list"
|
||||||
|
: "Hide this chat from your main list"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isLoading && (
|
||||||
|
<ActivityIndicator size="small" color={ACCENT} style={styles.optionLoader} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Bottom padding for safe area */}
|
||||||
|
<View style={styles.bottomPadding} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -24,12 +24,12 @@ const ACCENT = colors.premium.gold;
|
|||||||
* Video player component with native-like tap behavior.
|
* Video player component with native-like tap behavior.
|
||||||
* Supports play/pause, fullscreen, caching, and loading states.
|
* Supports play/pause, fullscreen, caching, and loading states.
|
||||||
*/
|
*/
|
||||||
export function ChatVideoPlayer({
|
const ChatVideoPlayerComponent: React.FC<ChatVideoPlayerProps> = ({
|
||||||
signedUrl,
|
signedUrl,
|
||||||
storagePath,
|
storagePath,
|
||||||
urlError,
|
urlError,
|
||||||
onFullScreen,
|
onFullScreen,
|
||||||
}: ChatVideoPlayerProps) {
|
}) => {
|
||||||
const videoRef = useRef<Video>(null);
|
const videoRef = useRef<Video>(null);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [hasEnded, setHasEnded] = useState(false);
|
const [hasEnded, setHasEnded] = useState(false);
|
||||||
@@ -43,11 +43,6 @@ export function ChatVideoPlayer({
|
|||||||
const isLocalFile = signedUrl?.startsWith('file://');
|
const isLocalFile = signedUrl?.startsWith('file://');
|
||||||
const videoSource = isLocalFile ? signedUrl : (cachedUri || signedUrl);
|
const videoSource = isLocalFile ? signedUrl : (cachedUri || signedUrl);
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
useEffect(() => {
|
|
||||||
console.log(`[ChatVideoPlayer] isLocalFile=${isLocalFile}, storagePath=${storagePath}, signedUrl=${signedUrl?.substring(0, 60)}..., videoSource=${videoSource?.substring(0, 60)}...`);
|
|
||||||
}, [storagePath, signedUrl, videoSource, isLocalFile]);
|
|
||||||
|
|
||||||
// Handle playback status updates
|
// Handle playback status updates
|
||||||
const handlePlaybackStatusUpdate = useCallback(async (status: AVPlaybackStatus) => {
|
const handlePlaybackStatusUpdate = useCallback(async (status: AVPlaybackStatus) => {
|
||||||
if (!status.isLoaded) {
|
if (!status.isLoaded) {
|
||||||
@@ -71,7 +66,7 @@ export function ChatVideoPlayer({
|
|||||||
cacheVideoFile();
|
cacheVideoFile();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [signedUrl, storagePath, cachedUri, isLocalFile]);
|
}, [signedUrl, cachedUri, isLocalFile, cacheVideoFile]);
|
||||||
|
|
||||||
// Toggle play/pause with native-like behavior
|
// Toggle play/pause with native-like behavior
|
||||||
const handleTapToPlay = useCallback(async () => {
|
const handleTapToPlay = useCallback(async () => {
|
||||||
@@ -93,7 +88,7 @@ export function ChatVideoPlayer({
|
|||||||
cacheVideoFile();
|
cacheVideoFile();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isPlaying, hasEnded, signedUrl, storagePath, cachedUri]);
|
}, [isPlaying, hasEnded, signedUrl, cachedUri, cacheVideoFile]);
|
||||||
|
|
||||||
// Handle full screen
|
// Handle full screen
|
||||||
const handleFullScreen = useCallback(() => {
|
const handleFullScreen = useCallback(() => {
|
||||||
@@ -180,7 +175,10 @@ export function ChatVideoPlayer({
|
|||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// Wrap with React.memo for performance
|
||||||
|
export const ChatVideoPlayer = React.memo(ChatVideoPlayerComponent);
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
@@ -245,5 +243,3 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ChatVideoPlayer;
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, StyleSheet, TouchableOpacity, Modal, ActivityIndicator, useWindowDimensions } from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { colors, gradients } from '../../../theme';
|
||||||
|
|
||||||
|
const ACCENT = colors.premium.gold;
|
||||||
|
|
||||||
|
interface FullScreenImageModalProps {
|
||||||
|
uri: string | null;
|
||||||
|
visible: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onLoadStart: () => void;
|
||||||
|
onLoadEnd: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FullScreenImageModalComponent: React.FC<FullScreenImageModalProps> = ({
|
||||||
|
uri,
|
||||||
|
visible,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onLoadStart,
|
||||||
|
onLoadEnd,
|
||||||
|
}) => {
|
||||||
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
// Use safe area insets for button positioning to avoid notch/dynamic island
|
||||||
|
const buttonTop = insets.top + 10;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.saveButton, { top: buttonTop }]}
|
||||||
|
onPress={onSave}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
disabled={saving}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Save image to device"
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.premiumGold as [string, string]}
|
||||||
|
style={styles.buttonGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.text} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="download-outline" size={24} color={colors.text} />
|
||||||
|
)}
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.closeButton, { top: buttonTop }]}
|
||||||
|
onPress={onClose}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Close full screen image"
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.premiumGold as [string, string]}
|
||||||
|
style={styles.buttonGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<ActivityIndicator size="large" color={ACCENT} style={styles.spinner} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uri && (
|
||||||
|
<Image
|
||||||
|
source={{ uri }}
|
||||||
|
style={{ width: windowWidth, height: windowHeight * 0.8 }}
|
||||||
|
contentFit="contain"
|
||||||
|
cachePolicy="disk"
|
||||||
|
onLoadStart={onLoadStart}
|
||||||
|
onLoadEnd={onLoadEnd}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FullScreenImageModal = React.memo(FullScreenImageModalComponent);
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.95)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 20,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 20,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
buttonGradient: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
spinner: {
|
||||||
|
position: 'absolute',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, StyleSheet, TouchableOpacity, Modal, ActivityIndicator, useWindowDimensions } from 'react-native';
|
||||||
|
import { Video, ResizeMode } from 'expo-av';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { colors, gradients } from '../../../theme';
|
||||||
|
|
||||||
|
interface FullScreenVideoModalProps {
|
||||||
|
uri: string | null;
|
||||||
|
visible: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FullScreenVideoModalComponent: React.FC<FullScreenVideoModalProps> = ({
|
||||||
|
uri,
|
||||||
|
visible,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}) => {
|
||||||
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
// Use safe area insets for button positioning to avoid notch/dynamic island
|
||||||
|
const buttonTop = insets.top + 10;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.saveButton, { top: buttonTop }]}
|
||||||
|
onPress={onSave}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
disabled={saving}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Save video to device"
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.premiumGold as [string, string]}
|
||||||
|
style={styles.buttonGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.text} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="download-outline" size={24} color={colors.text} />
|
||||||
|
)}
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.closeButton, { top: buttonTop }]}
|
||||||
|
onPress={onClose}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Close full screen video"
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.premiumGold as [string, string]}
|
||||||
|
style={styles.buttonGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{uri && (
|
||||||
|
<Video
|
||||||
|
source={{ uri }}
|
||||||
|
style={{ width: windowWidth, height: windowHeight * 0.8 }}
|
||||||
|
resizeMode={ResizeMode.CONTAIN}
|
||||||
|
useNativeControls
|
||||||
|
shouldPlay={visible}
|
||||||
|
isLooping={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FullScreenVideoModal = React.memo(FullScreenVideoModalComponent);
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.95)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 20,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 20,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
buttonGradient: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@ const ACCENT = colors.premium.gold;
|
|||||||
* Chat input bar with expandable media menu.
|
* Chat input bar with expandable media menu.
|
||||||
* Includes text input, send button, and media action buttons.
|
* Includes text input, send button, and media action buttons.
|
||||||
*/
|
*/
|
||||||
export function InputBar({
|
const InputBarComponent: React.FC<InputBarProps> = ({
|
||||||
inputText,
|
inputText,
|
||||||
uploading,
|
uploading,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
@@ -35,7 +35,7 @@ export function InputBar({
|
|||||||
onPickMedia,
|
onPickMedia,
|
||||||
onTakePhoto,
|
onTakePhoto,
|
||||||
onRecordVideo,
|
onRecordVideo,
|
||||||
}: InputBarProps) {
|
}) => {
|
||||||
const [menuExpanded, setMenuExpanded] = useState(false);
|
const [menuExpanded, setMenuExpanded] = useState(false);
|
||||||
const menuWidth = useSharedValue(0);
|
const menuWidth = useSharedValue(0);
|
||||||
const buttonRotation = useSharedValue(0);
|
const buttonRotation = useSharedValue(0);
|
||||||
@@ -176,7 +176,10 @@ export function InputBar({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// Wrap with React.memo for performance
|
||||||
|
export const InputBar = React.memo(InputBarComponent);
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
@@ -261,5 +264,3 @@ const styles = StyleSheet.create({
|
|||||||
height: 20,
|
height: 20,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default InputBar;
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
@@ -6,24 +6,38 @@ import Animated, { FadeInDown } from 'react-native-reanimated';
|
|||||||
|
|
||||||
import { DecorativeSeparator } from '../../../components/ui';
|
import { DecorativeSeparator } from '../../../components/ui';
|
||||||
import { colors, gradients, spacing, radius, typography } from '../../../theme';
|
import { colors, gradients, spacing, radius, typography } from '../../../theme';
|
||||||
|
import { Match } from '../types';
|
||||||
|
|
||||||
// Premium color palette
|
// Premium color palette
|
||||||
const ACCENT = colors.premium.gold;
|
const ACCENT = colors.premium.gold;
|
||||||
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
|
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
|
||||||
|
|
||||||
interface MatchCardProps {
|
interface MatchCardProps {
|
||||||
match: any;
|
match: Match | null;
|
||||||
user: { id: string } | null;
|
user: { id: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MatchCard: React.FC<MatchCardProps> = ({ match, user }) => {
|
const MatchCardComponent: React.FC<MatchCardProps> = ({ 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;
|
if (!match?.question) return null;
|
||||||
|
|
||||||
const isYesYes = match?.match_type === 'yes_yes';
|
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 (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(100).duration(400).springify()}
|
entering={enteringAnimation}
|
||||||
style={styles.matchCardContainer}
|
style={styles.matchCardContainer}
|
||||||
>
|
>
|
||||||
<View style={styles.matchCard}>
|
<View style={styles.matchCard}>
|
||||||
@@ -66,19 +80,19 @@ export const MatchCard: React.FC<MatchCardProps> = ({ match, user }) => {
|
|||||||
{(() => {
|
{(() => {
|
||||||
// Sort responses by created_at to determine who answered first
|
// Sort responses by created_at to determine who answered first
|
||||||
const sortedResponses = [...match.responses].sort(
|
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 isUser = response.user_id === user?.id;
|
||||||
const isYes = response.answer === 'yes';
|
const isYes = response.answer === 'yes';
|
||||||
const name = isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner');
|
const name = isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner');
|
||||||
|
|
||||||
// First responder sees main text, second sees partner_text
|
// First responder sees main text, second sees partner_text
|
||||||
const questionText = isTwoPart && index === 1
|
const questionText = isTwoPart && index === 1
|
||||||
? match.question.partner_text
|
? match.question?.partner_text
|
||||||
: match.question.text;
|
: match.question?.text;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View key={response.user_id} style={styles.responseCard}>
|
<View key={response.user_id} style={styles.responseCard}>
|
||||||
@@ -130,6 +144,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({ match, user }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wrap with React.memo for performance
|
||||||
|
export const MatchCard = React.memo(MatchCardComponent);
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
matchCardContainer: {
|
matchCardContainer: {
|
||||||
marginHorizontal: spacing.md,
|
marginHorizontal: spacing.md,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { colors, gradients, spacing, radius } from '../../../theme';
|
|||||||
|
|
||||||
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
|
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 {
|
export interface MessageBubbleProps {
|
||||||
isMe: boolean;
|
isMe: boolean;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -18,10 +21,18 @@ export interface MessageBubbleProps {
|
|||||||
onLongPress?: () => void;
|
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 (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInUp.delay(index * 30).duration(200)}
|
entering={enteringAnimation}
|
||||||
style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}
|
style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}
|
||||||
>
|
>
|
||||||
{isMe ? (
|
{isMe ? (
|
||||||
@@ -30,6 +41,9 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub
|
|||||||
activeOpacity={0.9}
|
activeOpacity={0.9}
|
||||||
delayLongPress={300}
|
delayLongPress={300}
|
||||||
style={styles.myBubbleContainer}
|
style={styles.myBubbleContainer}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
accessibilityHint="Long press to delete or report"
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={gradients.primary as [string, string]}
|
colors={gradients.primary as [string, string]}
|
||||||
@@ -53,6 +67,9 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub
|
|||||||
activeOpacity={0.9}
|
activeOpacity={0.9}
|
||||||
delayLongPress={300}
|
delayLongPress={300}
|
||||||
style={styles.theirBubbleContainer}
|
style={styles.theirBubbleContainer}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
accessibilityHint="Long press to report"
|
||||||
>
|
>
|
||||||
<View style={[styles.bubble, styles.theirBubble]}>
|
<View style={[styles.bubble, styles.theirBubble]}>
|
||||||
{/* Subtle gradient background */}
|
{/* Subtle gradient background */}
|
||||||
@@ -75,7 +92,10 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub
|
|||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// Wrap with React.memo for performance
|
||||||
|
export const MessageBubble = React.memo(MessageBubbleComponent);
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
messageRow: {
|
messageRow: {
|
||||||
@@ -116,5 +136,3 @@ const styles = StyleSheet.create({
|
|||||||
height: 30,
|
height: 30,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default MessageBubble;
|
|
||||||
@@ -22,18 +22,16 @@ export interface MessageContentProps {
|
|||||||
revealMessage: (id: string) => void;
|
revealMessage: (id: string) => void;
|
||||||
onImagePress: (uri: string) => void;
|
onImagePress: (uri: string) => void;
|
||||||
onVideoFullScreen: (uri: string) => void;
|
onVideoFullScreen: (uri: string) => void;
|
||||||
onLongPress?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageContent({
|
const MessageContentComponent: React.FC<MessageContentProps> = ({
|
||||||
item,
|
item,
|
||||||
isMe,
|
isMe,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
revealMessage,
|
revealMessage,
|
||||||
onImagePress,
|
onImagePress,
|
||||||
onVideoFullScreen,
|
onVideoFullScreen,
|
||||||
onLongPress,
|
}) => {
|
||||||
}: MessageContentProps) {
|
|
||||||
// Check if message was deleted for everyone
|
// Check if message was deleted for everyone
|
||||||
if (item.deleted_at) {
|
if (item.deleted_at) {
|
||||||
return (
|
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 isViewed = !!item.media_viewed_at;
|
||||||
const isRecipientHidden = !isMe && !isViewed;
|
const isRecipientHidden = !isMe && !isViewed;
|
||||||
|
|
||||||
@@ -87,7 +85,7 @@ export function MessageContent({
|
|||||||
}, [item.media_path, isRecipientHidden]);
|
}, [item.media_path, isRecipientHidden]);
|
||||||
|
|
||||||
// Handle expired videos
|
// Handle expired videos
|
||||||
const isExpired = !!(item as any).media_expired;
|
const isExpired = !!item.media_expired;
|
||||||
|
|
||||||
if (isExpired && isVideo) {
|
if (isExpired && isVideo) {
|
||||||
return (
|
return (
|
||||||
@@ -240,7 +238,10 @@ export function MessageContent({
|
|||||||
<MessageMeta item={item} isMe={isMe} />
|
<MessageMeta item={item} isMe={isMe} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// Wrap with React.memo for performance
|
||||||
|
export const MessageContent = React.memo(MessageContentComponent);
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
deletedContainer: {
|
deletedContainer: {
|
||||||
@@ -347,5 +348,3 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default MessageContent;
|
|
||||||
@@ -12,7 +12,7 @@ export interface MessageMetaProps {
|
|||||||
/**
|
/**
|
||||||
* Message metadata component showing timestamp and read receipts.
|
* Message metadata component showing timestamp and read receipts.
|
||||||
*/
|
*/
|
||||||
export function MessageMeta({ item, isMe }: MessageMetaProps) {
|
const MessageMetaComponent: React.FC<MessageMetaProps> = ({ item, isMe }) => {
|
||||||
const status = getReceiptStatus(item.delivered_at, item.read_at);
|
const status = getReceiptStatus(item.delivered_at, item.read_at);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +27,10 @@ export function MessageMeta({ item, isMe }: MessageMetaProps) {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// Wrap with React.memo for performance
|
||||||
|
export const MessageMeta = React.memo(MessageMetaComponent);
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
@@ -43,5 +46,3 @@ const styles = StyleSheet.create({
|
|||||||
marginLeft: spacing.xs,
|
marginLeft: spacing.xs,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default MessageMeta;
|
|
||||||
@@ -15,7 +15,7 @@ interface TypingDotProps {
|
|||||||
delay: number;
|
delay: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TypingDot({ delay }: TypingDotProps) {
|
const TypingDot = React.memo(function TypingDot({ delay }: TypingDotProps) {
|
||||||
const opacity = useSharedValue(0.4);
|
const opacity = useSharedValue(0.4);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,13 +43,13 @@ function TypingDot({ delay }: TypingDotProps) {
|
|||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Typing indicator component showing three animated dots
|
* Typing indicator component showing three animated dots
|
||||||
* when partner is typing.
|
* when partner is typing.
|
||||||
*/
|
*/
|
||||||
export function TypingIndicator() {
|
const TypingIndicatorComponent: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeIn.duration(200)}
|
entering={FadeIn.duration(200)}
|
||||||
@@ -71,7 +71,10 @@ export function TypingIndicator() {
|
|||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// Wrap with React.memo for performance
|
||||||
|
export const TypingIndicator = React.memo(TypingIndicatorComponent);
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
@@ -104,5 +107,3 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default TypingIndicator;
|
|
||||||
@@ -16,3 +16,6 @@ export { ChatHeader } from './ChatHeader';
|
|||||||
export { ChatMessages } from './ChatMessages';
|
export { ChatMessages } from './ChatMessages';
|
||||||
export { MatchCard } from './MatchCard';
|
export { MatchCard } from './MatchCard';
|
||||||
export { ReportMessageSheet } from './ReportMessageSheet';
|
export { ReportMessageSheet } from './ReportMessageSheet';
|
||||||
|
export { ChatSettingsSheet } from './ChatSettingsSheet';
|
||||||
|
export { FullScreenImageModal } from './FullScreenImageModal';
|
||||||
|
export { FullScreenVideoModal } from './FullScreenVideoModal';
|
||||||
@@ -25,6 +25,7 @@ export interface Match {
|
|||||||
responses?: Array<{
|
responses?: Array<{
|
||||||
user_id: string;
|
user_id: string;
|
||||||
answer: string;
|
answer: string;
|
||||||
|
created_at: string;
|
||||||
profiles?: {
|
profiles?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export const useMessageSubscription = (
|
|||||||
|
|
||||||
const fetchMessagesAndMarkRead = async () => {
|
const fetchMessagesAndMarkRead = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setAllMessages([]); // Clear previous chat's messages immediately
|
||||||
|
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from('messages')
|
.from('messages')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { RealtimeChannel } from '@supabase/supabase-js';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
export interface UseTypingIndicatorConfig {
|
export interface UseTypingIndicatorConfig {
|
||||||
@@ -10,6 +11,8 @@ export interface UseTypingIndicatorConfig {
|
|||||||
typingTimeout?: number;
|
typingTimeout?: number;
|
||||||
/** Whether the screen is currently focused */
|
/** Whether the screen is currently focused */
|
||||||
isFocused?: boolean;
|
isFocused?: boolean;
|
||||||
|
/** Throttle interval in ms for sending typing events. Default: 2000 */
|
||||||
|
throttleInterval?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseTypingIndicatorReturn {
|
export interface UseTypingIndicatorReturn {
|
||||||
@@ -22,6 +25,7 @@ export interface UseTypingIndicatorReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TYPING_TIMEOUT = 3000;
|
const DEFAULT_TYPING_TIMEOUT = 3000;
|
||||||
|
const DEFAULT_THROTTLE_INTERVAL = 2000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing typing indicator state and broadcast events.
|
* Hook for managing typing indicator state and broadcast events.
|
||||||
@@ -33,11 +37,19 @@ const DEFAULT_TYPING_TIMEOUT = 3000;
|
|||||||
export const useTypingIndicator = (
|
export const useTypingIndicator = (
|
||||||
config: UseTypingIndicatorConfig
|
config: UseTypingIndicatorConfig
|
||||||
): UseTypingIndicatorReturn => {
|
): 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 [partnerTyping, setPartnerTyping] = useState(false);
|
||||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const isFocusedRef = useRef(isFocused);
|
const isFocusedRef = useRef(isFocused);
|
||||||
|
const lastTypingEventRef = useRef<number>(0);
|
||||||
|
const channelRef = useRef<RealtimeChannel | null>(null);
|
||||||
|
|
||||||
// Update ref when focus changes and clear state if blurred
|
// Update ref when focus changes and clear state if blurred
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,6 +77,7 @@ export const useTypingIndicator = (
|
|||||||
if (!channelName || !userId) return;
|
if (!channelName || !userId) return;
|
||||||
|
|
||||||
const channel = supabase.channel(channelName);
|
const channel = supabase.channel(channelName);
|
||||||
|
channelRef.current = channel;
|
||||||
|
|
||||||
channel.on('broadcast', { event: 'typing' }, (payload) => {
|
channel.on('broadcast', { event: 'typing' }, (payload) => {
|
||||||
// Only process events if the screen is focused
|
// Only process events if the screen is focused
|
||||||
@@ -88,6 +101,7 @@ export const useTypingIndicator = (
|
|||||||
channel.subscribe();
|
channel.subscribe();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
channelRef.current = null;
|
||||||
supabase.removeChannel(channel);
|
supabase.removeChannel(channel);
|
||||||
if (typingTimeoutRef.current) {
|
if (typingTimeoutRef.current) {
|
||||||
clearTimeout(typingTimeoutRef.current);
|
clearTimeout(typingTimeoutRef.current);
|
||||||
@@ -96,17 +110,22 @@ export const useTypingIndicator = (
|
|||||||
}, [channelName, userId, typingTimeout]);
|
}, [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(() => {
|
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',
|
type: 'broadcast',
|
||||||
event: 'typing',
|
event: 'typing',
|
||||||
payload: { userId },
|
payload: { userId },
|
||||||
});
|
});
|
||||||
}, [channelName, userId]);
|
}, [userId, throttleInterval]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manually clear the typing indicator (e.g., when a message is received)
|
* Manually clear the typing indicator (e.g., when a message is received)
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import type { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
type IoniconsName = keyof typeof Ionicons.glyphMap;
|
type IoniconsName = keyof typeof Ionicons.glyphMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a string is a valid Ionicons name.
|
||||||
|
* Uses the actual Ionicons.glyphMap for runtime validation.
|
||||||
|
*/
|
||||||
|
function isValidIonicon(name: string): name is IoniconsName {
|
||||||
|
return name in Ionicons.glyphMap;
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy emoji mappings (for backwards compatibility with existing packs)
|
// Legacy emoji mappings (for backwards compatibility with existing packs)
|
||||||
const EMOJI_TO_IONICON: Record<string, IoniconsName> = {
|
const EMOJI_TO_IONICON: Record<string, IoniconsName> = {
|
||||||
// Career & Professional
|
// Career & Professional
|
||||||
@@ -49,63 +57,20 @@ export const PACK_ICON_MAP = EMOJI_TO_IONICON;
|
|||||||
|
|
||||||
export const DEFAULT_PACK_ICON: IoniconsName = "layers-outline";
|
export const DEFAULT_PACK_ICON: IoniconsName = "layers-outline";
|
||||||
|
|
||||||
// List of valid Ionicon names (subset we support)
|
|
||||||
const VALID_IONICONS = new Set([
|
|
||||||
"heart-outline",
|
|
||||||
"heart-half-outline",
|
|
||||||
"heart-circle-outline",
|
|
||||||
"chatbubbles-outline",
|
|
||||||
"chatbox-outline",
|
|
||||||
"mail-outline",
|
|
||||||
"flower-outline",
|
|
||||||
"wine-outline",
|
|
||||||
"restaurant-outline",
|
|
||||||
"cafe-outline",
|
|
||||||
"airplane-outline",
|
|
||||||
"car-outline",
|
|
||||||
"compass-outline",
|
|
||||||
"map-outline",
|
|
||||||
"home-outline",
|
|
||||||
"people-outline",
|
|
||||||
"person-outline",
|
|
||||||
"eye-off-outline",
|
|
||||||
"key-outline",
|
|
||||||
"lock-closed-outline",
|
|
||||||
"dice-outline",
|
|
||||||
"gift-outline",
|
|
||||||
"sparkles-outline",
|
|
||||||
"star-outline",
|
|
||||||
"flame-outline",
|
|
||||||
"flash-outline",
|
|
||||||
"moon-outline",
|
|
||||||
"sunny-outline",
|
|
||||||
"flag-outline",
|
|
||||||
"calendar-outline",
|
|
||||||
"checkbox-outline",
|
|
||||||
"trophy-outline",
|
|
||||||
"layers-outline",
|
|
||||||
"cube-outline",
|
|
||||||
"folder-outline",
|
|
||||||
"bookmark-outline",
|
|
||||||
"bulb-outline",
|
|
||||||
"color-wand-outline",
|
|
||||||
"sync-outline",
|
|
||||||
"refresh-outline",
|
|
||||||
"briefcase-outline",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts an icon value to an Ionicons name.
|
* Converts an icon value to an Ionicons name.
|
||||||
* Handles both:
|
* Handles both:
|
||||||
* - Ionicon names directly (new packs): "briefcase-outline" -> "briefcase-outline"
|
* - Any valid Ionicon name directly: "briefcase-outline" -> "briefcase-outline"
|
||||||
* - Legacy emojis (existing packs): "💼" -> "briefcase-outline"
|
* - Legacy emojis (existing packs): "💼" -> "briefcase-outline"
|
||||||
*/
|
*/
|
||||||
export function getPackIconName(icon: string | null | undefined): IoniconsName {
|
export function getPackIconName(icon: string | null | undefined): IoniconsName {
|
||||||
if (!icon) return DEFAULT_PACK_ICON;
|
if (!icon) return DEFAULT_PACK_ICON;
|
||||||
|
|
||||||
if (VALID_IONICONS.has(icon)) {
|
// Check if it's a valid Ionicon name (supports the full Ionicons set)
|
||||||
return icon as IoniconsName;
|
if (isValidIonicon(icon)) {
|
||||||
|
return icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to emoji mapping for legacy packs
|
||||||
return EMOJI_TO_IONICON[icon] || DEFAULT_PACK_ICON;
|
return EMOJI_TO_IONICON[icon] || DEFAULT_PACK_ICON;
|
||||||
}
|
}
|
||||||
@@ -5,4 +5,4 @@ export { usePacksStore } from "./packsStore";
|
|||||||
export { useMessageStore } from "./messageStore";
|
export { useMessageStore } from "./messageStore";
|
||||||
export { useSubscriptionStore } from "./subscriptionStore";
|
export { useSubscriptionStore } from "./subscriptionStore";
|
||||||
export { useResponsesStore, groupResponses } from "./responsesStore";
|
export { useResponsesStore, groupResponses } from "./responsesStore";
|
||||||
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption } from "./responsesStore";
|
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption, DateSortOrder } from "./responsesStore";
|
||||||
@@ -14,12 +14,24 @@ interface MatchState {
|
|||||||
page: number;
|
page: number;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
// Archive state
|
||||||
|
archivedMatches: Match[];
|
||||||
|
archivedMatchIds: Set<string>;
|
||||||
|
showArchived: boolean;
|
||||||
|
isLoadingArchived: boolean;
|
||||||
|
// Methods
|
||||||
fetchMatches: (refresh?: boolean) => Promise<void>;
|
fetchMatches: (refresh?: boolean) => Promise<void>;
|
||||||
markAsSeen: (matchId: string) => Promise<void>;
|
markAsSeen: (matchId: string) => Promise<void>;
|
||||||
markAllAsSeen: () => Promise<void>;
|
markAllAsSeen: () => Promise<void>;
|
||||||
addMatch: (match: Match) => void;
|
addMatch: (match: Match) => void;
|
||||||
updateMatchUnreadCount: (matchId: string, delta: number) => void;
|
updateMatchUnreadCount: (matchId: string, delta: number) => void;
|
||||||
clearMatches: () => void;
|
clearMatches: () => void;
|
||||||
|
// Archive methods
|
||||||
|
archiveMatch: (matchId: string) => Promise<void>;
|
||||||
|
unarchiveMatch: (matchId: string) => Promise<void>;
|
||||||
|
fetchArchivedMatches: () => Promise<void>;
|
||||||
|
toggleShowArchived: () => void;
|
||||||
|
isMatchArchived: (matchId: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMatchStore = create<MatchState>((set, get) => ({
|
export const useMatchStore = create<MatchState>((set, get) => ({
|
||||||
@@ -31,6 +43,11 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
page: 0,
|
page: 0,
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
error: null,
|
error: null,
|
||||||
|
// Archive state
|
||||||
|
archivedMatches: [],
|
||||||
|
archivedMatchIds: new Set<string>(),
|
||||||
|
showArchived: false,
|
||||||
|
isLoadingArchived: false,
|
||||||
|
|
||||||
fetchMatches: async (refresh = false) => {
|
fetchMatches: async (refresh = false) => {
|
||||||
const userId = useAuthStore.getState().user?.id;
|
const userId = useAuthStore.getState().user?.id;
|
||||||
@@ -57,7 +74,18 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
const from = currentPage * BATCH_SIZE;
|
const from = currentPage * BATCH_SIZE;
|
||||||
const to = from + BATCH_SIZE - 1;
|
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;
|
let totalCount = state.totalCount;
|
||||||
if (refresh) {
|
if (refresh) {
|
||||||
const { count, error: countError } = await supabase
|
const { count, error: countError } = await supabase
|
||||||
@@ -66,7 +94,8 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
.eq("couple_id", coupleId);
|
.eq("couple_id", coupleId);
|
||||||
|
|
||||||
if (!countError) {
|
if (!countError) {
|
||||||
totalCount = count ?? 0;
|
// Subtract archived count from total
|
||||||
|
totalCount = (count ?? 0) - archivedMatchIds.size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,9 +110,13 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
.range(from, to);
|
.range(from, to);
|
||||||
|
|
||||||
if (matchError) throw matchError;
|
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) {
|
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 {
|
} else {
|
||||||
set({ isLoadingMore: false, hasMore: false });
|
set({ isLoadingMore: false, hasMore: false });
|
||||||
}
|
}
|
||||||
@@ -91,7 +124,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all responses for these questions to determine who answered first
|
// 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[] = [];
|
let responses: any[] = [];
|
||||||
if (questionIds.length > 0) {
|
if (questionIds.length > 0) {
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
@@ -102,7 +135,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch unread message counts per match
|
// Fetch unread message counts per match
|
||||||
const matchIds = matches.map(m => m.id);
|
const matchIds = nonArchivedMatches.map(m => m.id);
|
||||||
let unreadCounts: Record<string, number> = {};
|
let unreadCounts: Record<string, number> = {};
|
||||||
|
|
||||||
if (userId && matchIds.length > 0) {
|
if (userId && matchIds.length > 0) {
|
||||||
@@ -123,7 +156,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = matches.map(match => ({
|
const data = nonArchivedMatches.map(match => ({
|
||||||
...match,
|
...match,
|
||||||
responses: responses.filter(r => r.question_id === match.question_id),
|
responses: responses.filter(r => r.question_id === match.question_id),
|
||||||
unreadCount: unreadCounts[match.id] || 0
|
unreadCount: unreadCounts[match.id] || 0
|
||||||
@@ -147,15 +180,28 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
// Ideally we should have a separate query for count, but we'll just count in current batch
|
// Ideally we should have a separate query for count, but we'll just count in current batch
|
||||||
const newMatchesInBatch = sortedData.filter((m) => m.is_new).length;
|
const newMatchesInBatch = sortedData.filter((m) => m.is_new).length;
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
matches: refresh ? sortedData : [...state.matches, ...sortedData],
|
// Deduplicate matches by ID when appending new pages
|
||||||
|
const mergedMatches = refresh
|
||||||
|
? sortedData
|
||||||
|
: [...state.matches, ...sortedData].reduce<Match[]>((acc, match) => {
|
||||||
|
if (!acc.some((m: Match) => m.id === match.id)) {
|
||||||
|
acc.push(match);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
matches: mergedMatches,
|
||||||
newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch,
|
newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch,
|
||||||
totalCount: refresh ? totalCount : state.totalCount,
|
totalCount: refresh ? totalCount : state.totalCount,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
page: currentPage + 1,
|
page: currentPage + 1,
|
||||||
hasMore: matches.length === BATCH_SIZE
|
hasMore: nonArchivedMatches.length === BATCH_SIZE,
|
||||||
}));
|
archivedMatchIds: refresh ? archivedMatchIds : state.archivedMatchIds,
|
||||||
|
};
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching matches:", err);
|
console.error("Error fetching matches:", err);
|
||||||
set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false });
|
set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false });
|
||||||
@@ -210,6 +256,195 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearMatches: () => {
|
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<string>(),
|
||||||
|
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<string, number> = {};
|
||||||
|
|
||||||
|
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);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -32,6 +32,7 @@ export interface UpdateResponseResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type GroupByOption = "pack" | "date" | "answer";
|
export type GroupByOption = "pack" | "date" | "answer";
|
||||||
|
export type DateSortOrder = "newest" | "oldest";
|
||||||
|
|
||||||
const BATCH_SIZE = 20;
|
const BATCH_SIZE = 20;
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ interface ResponsesState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
groupBy: GroupByOption;
|
groupBy: GroupByOption;
|
||||||
|
dateSortOrder: DateSortOrder;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
page: number;
|
page: number;
|
||||||
totalCount: number | null;
|
totalCount: number | null;
|
||||||
@@ -50,6 +52,8 @@ interface ResponsesState {
|
|||||||
confirmDelete?: boolean
|
confirmDelete?: boolean
|
||||||
) => Promise<UpdateResponseResult>;
|
) => Promise<UpdateResponseResult>;
|
||||||
setGroupBy: (groupBy: GroupByOption) => void;
|
setGroupBy: (groupBy: GroupByOption) => void;
|
||||||
|
setDateSortOrder: (order: DateSortOrder) => void;
|
||||||
|
toggleDateSortOrder: () => void;
|
||||||
clearResponses: () => void;
|
clearResponses: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +61,8 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
|||||||
responses: [],
|
responses: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
groupBy: "pack",
|
groupBy: "date",
|
||||||
|
dateSortOrder: "newest",
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
page: 0,
|
page: 0,
|
||||||
totalCount: null,
|
totalCount: null,
|
||||||
@@ -270,15 +275,26 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
|||||||
set({ groupBy });
|
set({ groupBy });
|
||||||
},
|
},
|
||||||
|
|
||||||
clearResponses: () => {
|
setDateSortOrder: (order: DateSortOrder) => {
|
||||||
set({ responses: [], isLoading: false, groupBy: "pack", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
|
set({ dateSortOrder: order });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleDateSortOrder: () => {
|
||||||
|
set((state) => ({
|
||||||
|
dateSortOrder: state.dateSortOrder === "newest" ? "oldest" : "newest",
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
clearResponses: () => {
|
||||||
|
set({ responses: [], isLoading: false, groupBy: "date", dateSortOrder: "newest", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Helper function to group responses
|
// Helper function to group responses
|
||||||
export function groupResponses(
|
export function groupResponses(
|
||||||
responses: ResponseWithQuestion[],
|
responses: ResponseWithQuestion[],
|
||||||
groupBy: GroupByOption
|
groupBy: GroupByOption,
|
||||||
|
dateSortOrder: DateSortOrder = "newest"
|
||||||
): { title: string; data: ResponseWithQuestion[] }[] {
|
): { title: string; data: ResponseWithQuestion[] }[] {
|
||||||
if (groupBy === "pack") {
|
if (groupBy === "pack") {
|
||||||
// Group by pack name
|
// Group by pack name
|
||||||
@@ -307,9 +323,16 @@ export function groupResponses(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (groupBy === "date") {
|
if (groupBy === "date") {
|
||||||
|
// Sort responses by date based on sort order
|
||||||
|
const sortedResponses = [...responses].sort((a, b) => {
|
||||||
|
const dateA = new Date(a.created_at).getTime();
|
||||||
|
const dateB = new Date(b.created_at).getTime();
|
||||||
|
return dateSortOrder === "newest" ? dateB - dateA : dateA - dateB;
|
||||||
|
});
|
||||||
|
|
||||||
// Group by date (day)
|
// Group by date (day)
|
||||||
const groups = new Map<string, ResponseWithQuestion[]>();
|
const groups = new Map<string, ResponseWithQuestion[]>();
|
||||||
responses.forEach((r) => {
|
sortedResponses.forEach((r) => {
|
||||||
const date = new Date(r.created_at).toLocaleDateString("en-US", {
|
const date = new Date(r.created_at).toLocaleDateString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
|
|||||||
@@ -100,6 +100,14 @@ export interface MatchWithQuestion extends Match {
|
|||||||
question: Question;
|
question: Question;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match archive (per-user)
|
||||||
|
export interface MatchArchive {
|
||||||
|
id: string;
|
||||||
|
match_id: string;
|
||||||
|
user_id: string;
|
||||||
|
archived_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Pack with progress info
|
// Pack with progress info
|
||||||
export interface PackProgress {
|
export interface PackProgress {
|
||||||
pack: QuestionPack;
|
pack: QuestionPack;
|
||||||
|
|||||||
@@ -98,18 +98,21 @@ export type Database = {
|
|||||||
app_config: {
|
app_config: {
|
||||||
Row: {
|
Row: {
|
||||||
answer_gap_threshold: number | null
|
answer_gap_threshold: number | null
|
||||||
|
daily_response_limit: number | null
|
||||||
id: string
|
id: string
|
||||||
updated_at: string | null
|
updated_at: string | null
|
||||||
updated_by: string | null
|
updated_by: string | null
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
answer_gap_threshold?: number | null
|
answer_gap_threshold?: number | null
|
||||||
|
daily_response_limit?: number | null
|
||||||
id?: string
|
id?: string
|
||||||
updated_at?: string | null
|
updated_at?: string | null
|
||||||
updated_by?: string | null
|
updated_by?: string | null
|
||||||
}
|
}
|
||||||
Update: {
|
Update: {
|
||||||
answer_gap_threshold?: number | null
|
answer_gap_threshold?: number | null
|
||||||
|
daily_response_limit?: number | null
|
||||||
id?: string
|
id?: string
|
||||||
updated_at?: string | null
|
updated_at?: string | null
|
||||||
updated_by?: string | null
|
updated_by?: string | null
|
||||||
@@ -373,6 +376,42 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Relationships: []
|
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: {
|
matches: {
|
||||||
Row: {
|
Row: {
|
||||||
couple_id: string
|
couple_id: string
|
||||||
@@ -925,6 +964,16 @@ export type Database = {
|
|||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
get_auth_user_couple_id: { Args: never; Returns: string }
|
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: {
|
get_feature_interest_counts: {
|
||||||
Args: never
|
Args: never
|
||||||
Returns: {
|
Returns: {
|
||||||
|
|||||||
@@ -9,6 +9,174 @@ const corsHeaders = {
|
|||||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
"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) => {
|
Deno.serve(async (req) => {
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
return new Response("ok", { headers: corsHeaders });
|
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 });
|
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");
|
const apiKey = config.openrouter_api_key || Deno.env.get("OPENROUTER_API_KEY");
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
console.error("OpenRouter API Key missing");
|
console.error("OpenRouter API Key missing");
|
||||||
@@ -64,15 +234,22 @@ Deno.serve(async (req) => {
|
|||||||
let decryptedText = message.content;
|
let decryptedText = message.content;
|
||||||
let mediaBase64: string | null = null;
|
let mediaBase64: string | null = null;
|
||||||
let mediaMimeType: 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) {
|
if (version === 2 && (message.encrypted_content || hasMedia)) {
|
||||||
const keysMetadata = message.keys_metadata as KeysMetadata;
|
|
||||||
const encryptionIv = message.encryption_iv;
|
|
||||||
|
|
||||||
if (!keysMetadata || !encryptionIv) {
|
if (!keysMetadata || !encryptionIv) {
|
||||||
return new Response(JSON.stringify({ error: "Missing encryption metadata" }), { status: 400, headers: corsHeaders });
|
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");
|
const adminPrivateKeyJwkStr = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
|
||||||
if (!adminPrivateKeyJwkStr) {
|
if (!adminPrivateKeyJwkStr) {
|
||||||
throw new Error("ADMIN_PRIVATE_KEY_JWK not set");
|
throw new Error("ADMIN_PRIVATE_KEY_JWK not set");
|
||||||
@@ -81,7 +258,7 @@ Deno.serve(async (req) => {
|
|||||||
const adminPrivateKey = JSON.parse(adminPrivateKeyJwkStr);
|
const adminPrivateKey = JSON.parse(adminPrivateKeyJwkStr);
|
||||||
const rawAesKey = await unwrapWithAdminKey(keysMetadata.admin_wrapped_key, adminPrivateKey);
|
const rawAesKey = await unwrapWithAdminKey(keysMetadata.admin_wrapped_key, adminPrivateKey);
|
||||||
|
|
||||||
const aesKey = await crypto.subtle.importKey(
|
aesKey = await crypto.subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
rawAesKey,
|
rawAesKey,
|
||||||
{ name: "AES-GCM" },
|
{ name: "AES-GCM" },
|
||||||
@@ -89,45 +266,60 @@ Deno.serve(async (req) => {
|
|||||||
["decrypt"]
|
["decrypt"]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Decrypt Text
|
return aesKey;
|
||||||
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 (version === 2 && message.encrypted_content) {
|
||||||
if (message.media_path && message.media_type === "image") {
|
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
|
const { data: mediaBlob, error: downloadError } = await supabaseAdmin.storage
|
||||||
.from("chat-media")
|
.from("chat-media")
|
||||||
.download(normalizeChatMediaPath(message.media_path));
|
.download(normalizeChatMediaPath(message.media_path));
|
||||||
|
|
||||||
if (!downloadError && mediaBlob) {
|
if (!downloadError && mediaBlob) {
|
||||||
const encryptedBytes = await mediaBlob.arrayBuffer();
|
const encryptedBytes = await mediaBlob.arrayBuffer();
|
||||||
const decryptedBytes = await crypto.subtle.decrypt(
|
const decryptedBytes = await crypto.subtle.decrypt(
|
||||||
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
|
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv as string) },
|
||||||
aesKey,
|
key,
|
||||||
encryptedBytes
|
encryptedBytes
|
||||||
);
|
);
|
||||||
mediaBase64 = arrayBufferToBase64(decryptedBytes);
|
mediaBase64 = arrayBufferToBase64(decryptedBytes);
|
||||||
mediaMimeType = "image/jpeg"; // Default or infer?
|
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
|
// 4. Construct LLM Payload
|
||||||
|
|||||||
@@ -77,6 +77,67 @@ Deno.serve(async (req) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Save or update response
|
||||||
const { data: response, error: responseError } = await supabase
|
const { data: response, error: responseError } = await supabase
|
||||||
.from("responses")
|
.from("responses")
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
-- Filter individual questions by user's max_intensity preference
|
||||||
|
-- This fixes a bug where users would see intense questions despite having a gentle comfort zone
|
||||||
|
|
||||||
|
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;
|
||||||
|
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 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;
|
||||||
|
|
||||||
|
v_has_premium := public.has_premium_access(v_user_id);
|
||||||
|
|
||||||
|
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 user is too far ahead of partner (answer gap threshold exceeded), returns empty.';
|
||||||
@@ -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;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
-- Dares Feature Migration
|
||||||
|
-- Creates dare_packs, dares, sent_dares, dare_messages tables
|
||||||
|
-- with RLS policies, triggers, and stats functions
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- CUSTOM TYPES
|
||||||
|
-- ============================================================================
|
||||||
|
CREATE TYPE dare_status AS ENUM (
|
||||||
|
'pending', -- Sent, waiting for recipient to accept/decline
|
||||||
|
'active', -- Recipient accepted, timer started (if applicable)
|
||||||
|
'completed', -- Sender marked as complete
|
||||||
|
'expired', -- Time ran out (only if had deadline)
|
||||||
|
'declined', -- Recipient declined
|
||||||
|
'cancelled' -- Sender cancelled before completion
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TABLES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Dare packs table (mirrors question_packs structure)
|
||||||
|
CREATE TABLE dare_packs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
icon TEXT,
|
||||||
|
is_premium BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
is_public BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
is_explicit BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
category_id UUID REFERENCES categories(id) ON DELETE SET NULL,
|
||||||
|
min_intensity INTEGER,
|
||||||
|
max_intensity INTEGER,
|
||||||
|
avg_intensity NUMERIC(3,2),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE dare_packs IS 'Collections of dares organized by theme or intensity level';
|
||||||
|
COMMENT ON COLUMN dare_packs.min_intensity IS 'Auto-calculated minimum intensity from dares in this pack';
|
||||||
|
COMMENT ON COLUMN dare_packs.max_intensity IS 'Auto-calculated maximum intensity from dares in this pack';
|
||||||
|
COMMENT ON COLUMN dare_packs.avg_intensity IS 'Auto-calculated average intensity from dares in this pack';
|
||||||
|
|
||||||
|
-- Create indexes for dare_packs
|
||||||
|
CREATE INDEX idx_dare_packs_sort_order ON dare_packs(sort_order);
|
||||||
|
CREATE INDEX idx_dare_packs_visibility ON dare_packs(is_public, is_premium);
|
||||||
|
CREATE INDEX idx_dare_packs_category ON dare_packs(category_id);
|
||||||
|
|
||||||
|
-- Dares table (individual dare content)
|
||||||
|
CREATE TABLE dares (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
pack_id UUID NOT NULL REFERENCES dare_packs(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
intensity INTEGER NOT NULL DEFAULT 1 CHECK (intensity >= 1 AND intensity <= 5),
|
||||||
|
suggested_duration_hours INTEGER,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE dares IS 'Individual dares within dare packs';
|
||||||
|
COMMENT ON COLUMN dares.intensity IS 'Difficulty/spiciness level from 1 (mild) to 5 (intense)';
|
||||||
|
COMMENT ON COLUMN dares.suggested_duration_hours IS 'Suggested time to complete the dare in hours (null = no suggestion)';
|
||||||
|
|
||||||
|
-- Create indexes for dares
|
||||||
|
CREATE INDEX idx_dares_pack_id ON dares(pack_id);
|
||||||
|
CREATE INDEX idx_dares_intensity ON dares(intensity);
|
||||||
|
|
||||||
|
-- Sent dares table (tracks dare instances between partners)
|
||||||
|
CREATE TABLE sent_dares (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
|
||||||
|
dare_id UUID REFERENCES dares(id) ON DELETE SET NULL,
|
||||||
|
custom_dare_text TEXT,
|
||||||
|
custom_dare_intensity INTEGER CHECK (custom_dare_intensity IS NULL OR (custom_dare_intensity >= 1 AND custom_dare_intensity <= 5)),
|
||||||
|
sender_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
recipient_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
status dare_status NOT NULL DEFAULT 'pending',
|
||||||
|
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
accepted_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
sender_notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT dare_or_custom CHECK (dare_id IS NOT NULL OR custom_dare_text IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE sent_dares IS 'Dare instances sent between partners in a couple';
|
||||||
|
COMMENT ON COLUMN sent_dares.dare_id IS 'Reference to the dare from a pack (null for custom dares)';
|
||||||
|
COMMENT ON COLUMN sent_dares.custom_dare_text IS 'Text for custom dares created by premium users';
|
||||||
|
COMMENT ON COLUMN sent_dares.custom_dare_intensity IS 'Intensity for custom dares (1-5)';
|
||||||
|
COMMENT ON COLUMN sent_dares.expires_at IS 'Deadline for completing the dare (null = no time limit)';
|
||||||
|
COMMENT ON COLUMN sent_dares.sender_notes IS 'Optional message from sender when sending the dare';
|
||||||
|
|
||||||
|
-- Create indexes for sent_dares
|
||||||
|
CREATE INDEX idx_sent_dares_couple_id ON sent_dares(couple_id);
|
||||||
|
CREATE INDEX idx_sent_dares_sender_id ON sent_dares(sender_id);
|
||||||
|
CREATE INDEX idx_sent_dares_recipient_id ON sent_dares(recipient_id);
|
||||||
|
CREATE INDEX idx_sent_dares_status ON sent_dares(status);
|
||||||
|
CREATE INDEX idx_sent_dares_expires_at ON sent_dares(expires_at) WHERE expires_at IS NOT NULL AND status = 'active';
|
||||||
|
|
||||||
|
-- Dare messages table (chat for each dare)
|
||||||
|
CREATE TABLE dare_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
sent_dare_id UUID NOT NULL REFERENCES sent_dares(id) ON DELETE CASCADE,
|
||||||
|
sender_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
read_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE dare_messages IS 'Chat messages associated with each sent dare';
|
||||||
|
|
||||||
|
-- Create indexes for dare_messages
|
||||||
|
CREATE INDEX idx_dare_messages_sent_dare_id ON dare_messages(sent_dare_id);
|
||||||
|
CREATE INDEX idx_dare_messages_created_at ON dare_messages(sent_dare_id, created_at);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TRIGGERS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Trigger function to auto-calculate dare pack intensity stats
|
||||||
|
CREATE OR REPLACE FUNCTION update_dare_pack_intensity_stats()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
target_pack_id UUID;
|
||||||
|
BEGIN
|
||||||
|
-- Get the pack_id to update
|
||||||
|
target_pack_id := COALESCE(NEW.pack_id, OLD.pack_id);
|
||||||
|
|
||||||
|
-- Update the dare pack stats
|
||||||
|
UPDATE dare_packs
|
||||||
|
SET
|
||||||
|
min_intensity = (SELECT MIN(intensity) FROM dares WHERE pack_id = target_pack_id),
|
||||||
|
max_intensity = (SELECT MAX(intensity) FROM dares WHERE pack_id = target_pack_id),
|
||||||
|
avg_intensity = (SELECT ROUND(AVG(intensity)::numeric, 2) FROM dares WHERE pack_id = target_pack_id)
|
||||||
|
WHERE id = target_pack_id;
|
||||||
|
|
||||||
|
RETURN COALESCE(NEW, OLD);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Create trigger on dares table
|
||||||
|
CREATE TRIGGER dare_intensity_stats_trigger
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON dares
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_dare_pack_intensity_stats();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- STATS FUNCTIONS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Function to get dare stats for a specific user
|
||||||
|
CREATE OR REPLACE FUNCTION get_user_dare_stats(p_user_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
dares_sent_count BIGINT,
|
||||||
|
dares_received_count BIGINT,
|
||||||
|
dares_completed_as_sender BIGINT,
|
||||||
|
dares_completed_as_recipient BIGINT
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE sender_id = p_user_id)::BIGINT,
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id)::BIGINT,
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE sender_id = p_user_id AND status = 'completed')::BIGINT,
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id AND status = 'completed')::BIGINT;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Function to get dare stats for a couple
|
||||||
|
CREATE OR REPLACE FUNCTION get_couple_dare_stats(p_couple_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
total_dares_sent BIGINT,
|
||||||
|
total_dares_completed BIGINT,
|
||||||
|
total_dares_active BIGINT
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id)::BIGINT,
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'completed')::BIGINT,
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'active')::BIGINT;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- ROW LEVEL SECURITY
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Enable RLS on all tables
|
||||||
|
ALTER TABLE dare_packs ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE dares ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE sent_dares ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE dare_messages ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- -------------------------
|
||||||
|
-- dare_packs policies
|
||||||
|
-- -------------------------
|
||||||
|
|
||||||
|
-- Anyone can view public dare packs
|
||||||
|
CREATE POLICY "Public dare packs are viewable by everyone"
|
||||||
|
ON dare_packs FOR SELECT
|
||||||
|
USING (is_public = true);
|
||||||
|
|
||||||
|
-- Admins can view all dare packs
|
||||||
|
CREATE POLICY "Admins can view all dare packs"
|
||||||
|
ON dare_packs FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can insert dare packs
|
||||||
|
CREATE POLICY "Admins can insert dare packs"
|
||||||
|
ON dare_packs FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can update dare packs
|
||||||
|
CREATE POLICY "Admins can update dare packs"
|
||||||
|
ON dare_packs FOR UPDATE
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can delete dare packs
|
||||||
|
CREATE POLICY "Admins can delete dare packs"
|
||||||
|
ON dare_packs FOR DELETE
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------
|
||||||
|
-- dares policies
|
||||||
|
-- -------------------------
|
||||||
|
|
||||||
|
-- Users can view dares in accessible packs (public and free, or public and user is premium)
|
||||||
|
CREATE POLICY "Users can view dares in accessible packs"
|
||||||
|
ON dares FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM dare_packs dp
|
||||||
|
WHERE dp.id = dares.pack_id
|
||||||
|
AND dp.is_public = true
|
||||||
|
AND (
|
||||||
|
dp.is_premium = false
|
||||||
|
OR EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND is_premium = true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can view all dares
|
||||||
|
CREATE POLICY "Admins can view all dares"
|
||||||
|
ON dares FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can insert dares
|
||||||
|
CREATE POLICY "Admins can insert dares"
|
||||||
|
ON dares FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can update dares
|
||||||
|
CREATE POLICY "Admins can update dares"
|
||||||
|
ON dares FOR UPDATE
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can delete dares
|
||||||
|
CREATE POLICY "Admins can delete dares"
|
||||||
|
ON dares FOR DELETE
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------
|
||||||
|
-- sent_dares policies
|
||||||
|
-- -------------------------
|
||||||
|
|
||||||
|
-- Users can view sent dares in their couple
|
||||||
|
CREATE POLICY "Users can view their couple's sent dares"
|
||||||
|
ON sent_dares FOR SELECT
|
||||||
|
USING (couple_id = get_auth_user_couple_id());
|
||||||
|
|
||||||
|
-- Users can insert sent dares (sender must be the authenticated user)
|
||||||
|
CREATE POLICY "Users can send dares to their partner"
|
||||||
|
ON sent_dares FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
sender_id = auth.uid()
|
||||||
|
AND couple_id = get_auth_user_couple_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users can update sent dares in their couple
|
||||||
|
CREATE POLICY "Users can update their couple's sent dares"
|
||||||
|
ON sent_dares FOR UPDATE
|
||||||
|
USING (couple_id = get_auth_user_couple_id());
|
||||||
|
|
||||||
|
-- Admins can view all sent dares
|
||||||
|
CREATE POLICY "Admins can view all sent dares"
|
||||||
|
ON sent_dares FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------
|
||||||
|
-- dare_messages policies
|
||||||
|
-- -------------------------
|
||||||
|
|
||||||
|
-- Users can view messages for their couple's dares
|
||||||
|
CREATE POLICY "Users can view messages for their dares"
|
||||||
|
ON dare_messages FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM sent_dares sd
|
||||||
|
WHERE sd.id = dare_messages.sent_dare_id
|
||||||
|
AND sd.couple_id = get_auth_user_couple_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users can insert messages for their couple's dares
|
||||||
|
CREATE POLICY "Users can send messages for their dares"
|
||||||
|
ON dare_messages FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
sender_id = auth.uid()
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM sent_dares sd
|
||||||
|
WHERE sd.id = dare_messages.sent_dare_id
|
||||||
|
AND sd.couple_id = get_auth_user_couple_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users can update messages in their dares (for read_at)
|
||||||
|
CREATE POLICY "Users can update message read status"
|
||||||
|
ON dare_messages FOR UPDATE
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM sent_dares sd
|
||||||
|
WHERE sd.id = dare_messages.sent_dare_id
|
||||||
|
AND sd.couple_id = get_auth_user_couple_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can view all dare messages
|
||||||
|
CREATE POLICY "Admins can view all dare messages"
|
||||||
|
ON dare_messages FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- REALTIME
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Add tables to realtime publication for live updates
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE sent_dares;
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE dare_messages;
|
||||||
@@ -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.';
|
||||||
@@ -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.';
|
||||||
@@ -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.';
|
||||||
@@ -4,4 +4,5 @@ NEXT_PUBLIC_POSTHOG_KEY=phc_your_project_key_here
|
|||||||
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
||||||
|
|
||||||
# Supabase
|
# Supabase
|
||||||
|
# Production: https://ckjcrkjpmhqhiucifukx.supabase.co
|
||||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||||
@@ -6,9 +6,11 @@ WORKDIR /app
|
|||||||
# Build args for Next.js public env vars (inlined at build time)
|
# Build args for Next.js public env vars (inlined at build time)
|
||||||
ARG NEXT_PUBLIC_POSTHOG_KEY
|
ARG NEXT_PUBLIC_POSTHOG_KEY
|
||||||
ARG NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
ARG NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
||||||
|
ARG NEXT_PUBLIC_SUPABASE_URL=https://ckjcrkjpmhqhiucifukx.supabase.co
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY
|
ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY
|
||||||
ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST
|
ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST
|
||||||
|
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ services:
|
|||||||
args:
|
args:
|
||||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||||
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-https://eu.i.posthog.com}
|
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-https://eu.i.posthog.com}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL:-https://ckjcrkjpmhqhiucifukx.supabase.co}
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- NEXT_TELEMETRY_DISABLED=1
|
- NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|||||||
@@ -0,0 +1,625 @@
|
|||||||
|
# Dares Feature - Architecture & Implementation Plan
|
||||||
|
|
||||||
|
> **Status**: Phase 1 Complete (Database + Admin Portal)
|
||||||
|
> **Created**: 2026-01-08
|
||||||
|
> **Updated**: 2026-01-08
|
||||||
|
> **Target**: Schema + Admin Portal (Phase 1 - DONE), Mobile App (Phase 2 - Later)
|
||||||
|
|
||||||
|
## Implementation Summary
|
||||||
|
|
||||||
|
### Completed (Phase 1)
|
||||||
|
|
||||||
|
| Component | Files | Status |
|
||||||
|
|-----------|-------|--------|
|
||||||
|
| Database Migration | `apps/supabase/migrations/20260109000000_create_dares_feature.sql` | Applied to prod |
|
||||||
|
| Dare Packs Page | `apps/admin/src/pages/content/DarePacksPage.tsx` | Complete |
|
||||||
|
| Dares Page | `apps/admin/src/pages/content/DaresPage.tsx` | Complete |
|
||||||
|
| Routes | `apps/admin/src/App.tsx` | Updated |
|
||||||
|
| Sidebar | `apps/admin/src/components/layout/Sidebar.tsx` | Updated |
|
||||||
|
|
||||||
|
### Database Tables Created
|
||||||
|
|
||||||
|
- `dare_packs` - Collections of dares (0 rows)
|
||||||
|
- `dares` - Individual dare content (0 rows)
|
||||||
|
- `sent_dares` - Dare instances between partners (0 rows)
|
||||||
|
- `dare_messages` - Chat messages for dares (0 rows)
|
||||||
|
- `dare_status` enum type with states: pending, active, completed, expired, declined, cancelled
|
||||||
|
|
||||||
|
### Admin Routes Added
|
||||||
|
|
||||||
|
- `/dare-packs` - List all dare packs
|
||||||
|
- `/dare-packs/:packId/dares` - List dares in a pack
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Dares feature allows users to send challenges/dares to their partner from curated packs. Partners can accept or decline dares, and the sender marks them as complete. A scoreboard tracks total completed dares per user/couple.
|
||||||
|
|
||||||
|
### Key Decisions
|
||||||
|
|
||||||
|
| Aspect | Decision |
|
||||||
|
|--------|----------|
|
||||||
|
| Dare Selection | User browses list, picks dare to send |
|
||||||
|
| Decline | Recipient can decline (no negative impact) |
|
||||||
|
| Cancel | Sender can cancel before completion |
|
||||||
|
| Timeframes | Pick from preset list + "no time limit" option |
|
||||||
|
| Scoreboard | Simple count of completed dares |
|
||||||
|
| Custom Dares | Premium users can create their own |
|
||||||
|
| Gender Filtering | Not needed (user chooses which dare to send) |
|
||||||
|
| Premium Model | Same as question packs (free/premium packs) |
|
||||||
|
| Chat | Auto-create chat thread when dare is sent |
|
||||||
|
| Completion | Sender marks dare as complete |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### New Enum Types
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TYPE dare_status AS ENUM (
|
||||||
|
'pending', -- Sent, waiting for recipient to accept/decline
|
||||||
|
'active', -- Recipient accepted, timer started (if applicable)
|
||||||
|
'completed', -- Sender marked as complete
|
||||||
|
'expired', -- Time ran out (only if had deadline)
|
||||||
|
'declined', -- Recipient declined
|
||||||
|
'cancelled' -- Sender cancelled before completion
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table: `dare_packs`
|
||||||
|
|
||||||
|
Mirrors `question_packs` structure for consistency.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Default | Description |
|
||||||
|
|--------|------|----------|---------|-------------|
|
||||||
|
| `id` | UUID | NO | `gen_random_uuid()` | Primary key |
|
||||||
|
| `name` | TEXT | NO | | Pack name |
|
||||||
|
| `description` | TEXT | YES | | Pack description |
|
||||||
|
| `icon` | TEXT | YES | | Emoji icon |
|
||||||
|
| `is_premium` | BOOLEAN | NO | `false` | Requires premium access |
|
||||||
|
| `is_public` | BOOLEAN | NO | `true` | Visible to users |
|
||||||
|
| `is_explicit` | BOOLEAN | NO | `false` | Contains explicit content |
|
||||||
|
| `sort_order` | INTEGER | NO | `0` | Display ordering |
|
||||||
|
| `category_id` | UUID | YES | | FK to `categories` (optional) |
|
||||||
|
| `min_intensity` | INTEGER | YES | | Auto-calculated from dares |
|
||||||
|
| `max_intensity` | INTEGER | YES | | Auto-calculated from dares |
|
||||||
|
| `avg_intensity` | NUMERIC(3,2) | YES | | Auto-calculated from dares |
|
||||||
|
| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- Primary key on `id`
|
||||||
|
- Index on `sort_order` for ordering
|
||||||
|
- Index on `is_public, is_premium` for filtering
|
||||||
|
|
||||||
|
### Table: `dares`
|
||||||
|
|
||||||
|
Individual dare content within packs.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Default | Description |
|
||||||
|
|--------|------|----------|---------|-------------|
|
||||||
|
| `id` | UUID | NO | `gen_random_uuid()` | Primary key |
|
||||||
|
| `pack_id` | UUID | NO | | FK to `dare_packs` (CASCADE delete) |
|
||||||
|
| `text` | TEXT | NO | | Dare description/instruction |
|
||||||
|
| `intensity` | INTEGER | NO | `1` | 1-5 scale (CHECK constraint) |
|
||||||
|
| `suggested_duration_hours` | INTEGER | YES | | Suggested timeframe in hours |
|
||||||
|
| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp |
|
||||||
|
|
||||||
|
**Constraints:**
|
||||||
|
- `CHECK (intensity >= 1 AND intensity <= 5)`
|
||||||
|
- Foreign key to `dare_packs` with `ON DELETE CASCADE`
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- Primary key on `id`
|
||||||
|
- Index on `pack_id` for lookups
|
||||||
|
|
||||||
|
### Table: `sent_dares`
|
||||||
|
|
||||||
|
Tracks dare instances sent between partners.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Default | Description |
|
||||||
|
|--------|------|----------|---------|-------------|
|
||||||
|
| `id` | UUID | NO | `gen_random_uuid()` | Primary key |
|
||||||
|
| `couple_id` | UUID | NO | | FK to `couples` (CASCADE delete) |
|
||||||
|
| `dare_id` | UUID | YES | | FK to `dares` (null for custom dares) |
|
||||||
|
| `custom_dare_text` | TEXT | YES | | Text for custom dares |
|
||||||
|
| `custom_dare_intensity` | INTEGER | YES | | Intensity for custom dares (1-5) |
|
||||||
|
| `sender_id` | UUID | NO | | FK to `profiles` |
|
||||||
|
| `recipient_id` | UUID | NO | | FK to `profiles` |
|
||||||
|
| `status` | `dare_status` | NO | `'pending'` | Current status |
|
||||||
|
| `sent_at` | TIMESTAMPTZ | NO | `now()` | When dare was sent |
|
||||||
|
| `accepted_at` | TIMESTAMPTZ | YES | | When recipient accepted |
|
||||||
|
| `expires_at` | TIMESTAMPTZ | YES | | Deadline (null = no limit) |
|
||||||
|
| `completed_at` | TIMESTAMPTZ | YES | | When marked complete |
|
||||||
|
| `sender_notes` | TEXT | YES | | Optional message from sender |
|
||||||
|
| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp |
|
||||||
|
|
||||||
|
**Constraints:**
|
||||||
|
- `CHECK (custom_dare_intensity IS NULL OR (custom_dare_intensity >= 1 AND custom_dare_intensity <= 5))`
|
||||||
|
- `CHECK ((dare_id IS NOT NULL) OR (custom_dare_text IS NOT NULL))` -- Must have either a dare_id or custom text
|
||||||
|
- Foreign keys with appropriate cascade behavior
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- Primary key on `id`
|
||||||
|
- Index on `couple_id` for couple lookups
|
||||||
|
- Index on `sender_id` for sender stats
|
||||||
|
- Index on `recipient_id` for recipient stats
|
||||||
|
- Index on `status` for filtering active dares
|
||||||
|
- Index on `expires_at` for expiry job
|
||||||
|
|
||||||
|
### Table: `dare_messages`
|
||||||
|
|
||||||
|
Chat messages for each dare instance.
|
||||||
|
|
||||||
|
| Column | Type | Nullable | Default | Description |
|
||||||
|
|--------|------|----------|---------|-------------|
|
||||||
|
| `id` | UUID | NO | `gen_random_uuid()` | Primary key |
|
||||||
|
| `sent_dare_id` | UUID | NO | | FK to `sent_dares` (CASCADE delete) |
|
||||||
|
| `sender_id` | UUID | NO | | FK to `profiles` |
|
||||||
|
| `content` | TEXT | NO | | Message content |
|
||||||
|
| `read_at` | TIMESTAMPTZ | YES | | When recipient read message |
|
||||||
|
| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- Primary key on `id`
|
||||||
|
- Index on `sent_dare_id` for message lookups
|
||||||
|
- Index on `created_at` for ordering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Functions & Triggers
|
||||||
|
|
||||||
|
### 1. Intensity Stats Trigger
|
||||||
|
|
||||||
|
Auto-calculate `min_intensity`, `max_intensity`, `avg_intensity` on `dare_packs` when dares are inserted/updated/deleted.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION update_dare_pack_intensity_stats()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE dare_packs
|
||||||
|
SET
|
||||||
|
min_intensity = (SELECT MIN(intensity) FROM dares WHERE pack_id = COALESCE(NEW.pack_id, OLD.pack_id)),
|
||||||
|
max_intensity = (SELECT MAX(intensity) FROM dares WHERE pack_id = COALESCE(NEW.pack_id, OLD.pack_id)),
|
||||||
|
avg_intensity = (SELECT ROUND(AVG(intensity)::numeric, 2) FROM dares WHERE pack_id = COALESCE(NEW.pack_id, OLD.pack_id))
|
||||||
|
WHERE id = COALESCE(NEW.pack_id, OLD.pack_id);
|
||||||
|
RETURN COALESCE(NEW, OLD);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER dare_intensity_stats_trigger
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON dares
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_dare_pack_intensity_stats();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. User Dare Stats Function
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION get_user_dare_stats(p_user_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
dares_sent_count BIGINT,
|
||||||
|
dares_received_count BIGINT,
|
||||||
|
dares_completed_as_sender BIGINT,
|
||||||
|
dares_completed_as_recipient BIGINT
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE sender_id = p_user_id),
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id),
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE sender_id = p_user_id AND status = 'completed'),
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id AND status = 'completed');
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Couple Dare Stats Function
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION get_couple_dare_stats(p_couple_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
total_dares_sent BIGINT,
|
||||||
|
total_dares_completed BIGINT,
|
||||||
|
total_dares_active BIGINT
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id),
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'completed'),
|
||||||
|
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'active');
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RLS Policies
|
||||||
|
|
||||||
|
### `dare_packs`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Anyone can view public packs
|
||||||
|
CREATE POLICY "Public dare packs are viewable by everyone"
|
||||||
|
ON dare_packs FOR SELECT
|
||||||
|
USING (is_public = true);
|
||||||
|
|
||||||
|
-- Admins can do everything
|
||||||
|
CREATE POLICY "Admins can manage dare packs"
|
||||||
|
ON dare_packs FOR ALL
|
||||||
|
USING (is_admin());
|
||||||
|
```
|
||||||
|
|
||||||
|
### `dares`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- View dares if pack is accessible (public, or premium pack + premium user)
|
||||||
|
CREATE POLICY "Users can view dares in accessible packs"
|
||||||
|
ON dares FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM dare_packs dp
|
||||||
|
WHERE dp.id = dares.pack_id
|
||||||
|
AND dp.is_public = true
|
||||||
|
AND (
|
||||||
|
dp.is_premium = false
|
||||||
|
OR EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND is_premium = true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins can do everything
|
||||||
|
CREATE POLICY "Admins can manage dares"
|
||||||
|
ON dares FOR ALL
|
||||||
|
USING (is_admin());
|
||||||
|
```
|
||||||
|
|
||||||
|
### `sent_dares`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Users can view sent dares in their couple
|
||||||
|
CREATE POLICY "Users can view their couple's sent dares"
|
||||||
|
ON sent_dares FOR SELECT
|
||||||
|
USING (couple_id = get_auth_user_couple_id());
|
||||||
|
|
||||||
|
-- Users can update sent dares in their couple (for status changes)
|
||||||
|
CREATE POLICY "Users can update their couple's sent dares"
|
||||||
|
ON sent_dares FOR UPDATE
|
||||||
|
USING (couple_id = get_auth_user_couple_id());
|
||||||
|
|
||||||
|
-- Insert via edge function only (service role)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `dare_messages`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Users can view messages for their couple's dares
|
||||||
|
CREATE POLICY "Users can view messages for their dares"
|
||||||
|
ON dare_messages FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM sent_dares sd
|
||||||
|
WHERE sd.id = dare_messages.sent_dare_id
|
||||||
|
AND sd.couple_id = get_auth_user_couple_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users can insert messages for their couple's dares
|
||||||
|
CREATE POLICY "Users can send messages for their dares"
|
||||||
|
ON dare_messages FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
sender_id = auth.uid()
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM sent_dares sd
|
||||||
|
WHERE sd.id = dare_messages.sent_dare_id
|
||||||
|
AND sd.couple_id = get_auth_user_couple_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users can update their own messages (for read_at)
|
||||||
|
CREATE POLICY "Users can update read status"
|
||||||
|
ON dare_messages FOR UPDATE
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM sent_dares sd
|
||||||
|
WHERE sd.id = dare_messages.sent_dare_id
|
||||||
|
AND sd.couple_id = get_auth_user_couple_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Preset Duration Options
|
||||||
|
|
||||||
|
App constants (not in database, but used in app/admin):
|
||||||
|
|
||||||
|
| Label | Value (hours) | Display |
|
||||||
|
|-------|---------------|---------|
|
||||||
|
| 1 hour | `1` | "1 hour" |
|
||||||
|
| 6 hours | `6` | "6 hours" |
|
||||||
|
| 12 hours | `12` | "12 hours" |
|
||||||
|
| 24 hours | `24` | "24 hours" |
|
||||||
|
| 3 days | `72` | "3 days" |
|
||||||
|
| 1 week | `168` | "1 week" |
|
||||||
|
| No time limit | `null` | "No time limit" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Functions (Future - App Phase)
|
||||||
|
|
||||||
|
### `send-dare`
|
||||||
|
|
||||||
|
Send a dare to partner.
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```typescript
|
||||||
|
interface SendDareInput {
|
||||||
|
dare_id?: string; // null for custom dare
|
||||||
|
custom_dare_text?: string; // required if dare_id is null
|
||||||
|
custom_dare_intensity?: number; // 1-5, required if custom
|
||||||
|
duration_hours?: number; // null = no limit
|
||||||
|
sender_notes?: string; // optional message
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
1. Validate user is in a couple
|
||||||
|
2. Get partner ID from couple
|
||||||
|
3. If custom dare, validate user is premium
|
||||||
|
4. Create `sent_dares` record with status `'pending'`
|
||||||
|
5. Calculate `expires_at` if duration provided
|
||||||
|
6. Trigger push notification to recipient
|
||||||
|
|
||||||
|
### `respond-dare`
|
||||||
|
|
||||||
|
Accept or decline a dare.
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```typescript
|
||||||
|
interface RespondDareInput {
|
||||||
|
sent_dare_id: string;
|
||||||
|
action: 'accept' | 'decline';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
1. Validate user is recipient of the dare
|
||||||
|
2. Validate status is `'pending'`
|
||||||
|
3. If accept: set status to `'active'`, set `accepted_at`
|
||||||
|
4. If decline: set status to `'declined'`
|
||||||
|
5. Trigger push notification to sender
|
||||||
|
|
||||||
|
### `complete-dare`
|
||||||
|
|
||||||
|
Sender marks dare as complete.
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```typescript
|
||||||
|
interface CompleteDareInput {
|
||||||
|
sent_dare_id: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
1. Validate user is sender of the dare
|
||||||
|
2. Validate status is `'active'`
|
||||||
|
3. Set status to `'completed'`, set `completed_at`
|
||||||
|
4. Trigger push notification to recipient
|
||||||
|
|
||||||
|
### `cancel-dare`
|
||||||
|
|
||||||
|
Sender cancels a dare.
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```typescript
|
||||||
|
interface CancelDareInput {
|
||||||
|
sent_dare_id: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
1. Validate user is sender
|
||||||
|
2. Validate status is `'pending'` or `'active'`
|
||||||
|
3. Set status to `'cancelled'`
|
||||||
|
4. Trigger push notification to recipient
|
||||||
|
|
||||||
|
### Background Job: Expire Dares
|
||||||
|
|
||||||
|
Cron job (pg_cron or external) to expire overdue dares:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE sent_dares
|
||||||
|
SET status = 'expired'
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND expires_at IS NOT NULL
|
||||||
|
AND expires_at < NOW();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notifications (Future - App Phase)
|
||||||
|
|
||||||
|
| Event | Recipient | Title | Body |
|
||||||
|
|-------|-----------|-------|------|
|
||||||
|
| Dare sent | Partner | "New Dare!" | "{name} sent you a dare" |
|
||||||
|
| Dare accepted | Sender | "Dare Accepted" | "{name} accepted your dare" |
|
||||||
|
| Dare declined | Sender | "Dare Declined" | "{name} declined your dare" |
|
||||||
|
| Dare completed | Recipient | "Dare Complete!" | "Your dare was marked complete" |
|
||||||
|
| Dare cancelled | Recipient | "Dare Cancelled" | "{name} cancelled the dare" |
|
||||||
|
| Dare expiring (1hr) | Recipient | "Dare Expiring Soon" | "Your dare expires in 1 hour" |
|
||||||
|
| Dare expired | Both | "Dare Expired" | "The dare has expired" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Portal Implementation
|
||||||
|
|
||||||
|
### New Files Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/admin/src/
|
||||||
|
├── pages/
|
||||||
|
│ ├── DarePacksPage.tsx # List all dare packs
|
||||||
|
│ ├── DarePackDetailPage.tsx # Create/edit dare pack
|
||||||
|
│ ├── DaresPage.tsx # List dares in a pack
|
||||||
|
│ └── DareDetailPage.tsx # Create/edit individual dare
|
||||||
|
├── components/
|
||||||
|
│ └── IntensityIndicator.tsx # Shared intensity display (1-5)
|
||||||
|
└── App.tsx # Add new routes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routes to Add
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In App.tsx
|
||||||
|
<Route path="/dare-packs" element={<DarePacksPage />} />
|
||||||
|
<Route path="/dare-packs/new" element={<DarePackDetailPage />} />
|
||||||
|
<Route path="/dare-packs/:packId" element={<DarePackDetailPage />} />
|
||||||
|
<Route path="/dare-packs/:packId/dares" element={<DaresPage />} />
|
||||||
|
<Route path="/dare-packs/:packId/dares/new" element={<DareDetailPage />} />
|
||||||
|
<Route path="/dare-packs/:packId/dares/:dareId" element={<DareDetailPage />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sidebar Navigation Update
|
||||||
|
|
||||||
|
Add under "Content" section:
|
||||||
|
```
|
||||||
|
📦 Content
|
||||||
|
├── Question Packs
|
||||||
|
└── Dare Packs (new)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Page Specifications
|
||||||
|
|
||||||
|
#### DarePacksPage
|
||||||
|
|
||||||
|
- **Table columns**: Icon, Name, Description (truncated), Dares Count, Intensity Range, Premium, Public, Actions
|
||||||
|
- **Actions**: Edit, Delete, View Dares
|
||||||
|
- **Create button**: Top right
|
||||||
|
- **Empty state**: "No dare packs yet. Create your first pack!"
|
||||||
|
|
||||||
|
#### DarePackDetailPage
|
||||||
|
|
||||||
|
- **Form fields**:
|
||||||
|
- Name (text input, required)
|
||||||
|
- Description (textarea)
|
||||||
|
- Icon (emoji picker or text input)
|
||||||
|
- Category (dropdown, optional)
|
||||||
|
- Is Premium (toggle)
|
||||||
|
- Is Public (toggle)
|
||||||
|
- Is Explicit (toggle)
|
||||||
|
- Sort Order (number input)
|
||||||
|
- **Read-only display**: Intensity stats (min/max/avg)
|
||||||
|
- **Actions**: Save, Cancel, Delete (if editing)
|
||||||
|
|
||||||
|
#### DaresPage
|
||||||
|
|
||||||
|
- **Table columns**: Text (truncated), Intensity (visual indicator), Suggested Duration, Actions
|
||||||
|
- **Actions**: Edit, Delete
|
||||||
|
- **Create button**: Top right
|
||||||
|
- **Back link**: To dare pack detail
|
||||||
|
|
||||||
|
#### DareDetailPage
|
||||||
|
|
||||||
|
- **Form fields**:
|
||||||
|
- Text (textarea, required)
|
||||||
|
- Intensity (slider 1-5 with labels)
|
||||||
|
- Suggested Duration (dropdown with preset options + "None")
|
||||||
|
- **Actions**: Save, Cancel, Delete (if editing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Tasks
|
||||||
|
|
||||||
|
### Phase 1: Database Schema (Supabase)
|
||||||
|
|
||||||
|
- [ ] **1.1** Create migration: `dare_status` enum type
|
||||||
|
- [ ] **1.2** Create migration: `dare_packs` table
|
||||||
|
- [ ] **1.3** Create migration: `dares` table with intensity trigger
|
||||||
|
- [ ] **1.4** Create migration: `sent_dares` table
|
||||||
|
- [ ] **1.5** Create migration: `dare_messages` table
|
||||||
|
- [ ] **1.6** Create migration: RLS policies for all tables
|
||||||
|
- [ ] **1.7** Create migration: Stats functions (`get_user_dare_stats`, `get_couple_dare_stats`)
|
||||||
|
- [ ] **1.8** Create migration: Add tables to realtime publication
|
||||||
|
- [ ] **1.9** Test migrations in non-prod environment
|
||||||
|
- [ ] **1.10** Apply migrations to production
|
||||||
|
|
||||||
|
### Phase 2: Admin Portal
|
||||||
|
|
||||||
|
- [ ] **2.1** Create `IntensityIndicator` component (if not exists)
|
||||||
|
- [ ] **2.2** Create `DarePacksPage` - list view
|
||||||
|
- [ ] **2.3** Create `DarePackDetailPage` - create/edit form
|
||||||
|
- [ ] **2.4** Create `DaresPage` - list dares in pack
|
||||||
|
- [ ] **2.5** Create `DareDetailPage` - create/edit dare form
|
||||||
|
- [ ] **2.6** Add routes to `App.tsx`
|
||||||
|
- [ ] **2.7** Update sidebar navigation
|
||||||
|
- [ ] **2.8** Test full CRUD flow in admin portal
|
||||||
|
|
||||||
|
### Phase 3: Edge Functions (Future - App Phase)
|
||||||
|
|
||||||
|
- [ ] **3.1** Create `send-dare` edge function
|
||||||
|
- [ ] **3.2** Create `respond-dare` edge function
|
||||||
|
- [ ] **3.3** Create `complete-dare` edge function
|
||||||
|
- [ ] **3.4** Create `cancel-dare` edge function
|
||||||
|
- [ ] **3.5** Set up dare expiry cron job
|
||||||
|
- [ ] **3.6** Integrate with notification system
|
||||||
|
|
||||||
|
### Phase 4: Mobile App (Future)
|
||||||
|
|
||||||
|
- [ ] **4.1** Create dare packs browsing UI
|
||||||
|
- [ ] **4.2** Create dare selection/sending flow
|
||||||
|
- [ ] **4.3** Create incoming dare notification + response UI
|
||||||
|
- [ ] **4.4** Create active dare tracking UI
|
||||||
|
- [ ] **4.5** Create dare chat integration
|
||||||
|
- [ ] **4.6** Create scoreboard/stats display
|
||||||
|
- [ ] **4.7** Create custom dare creation (premium)
|
||||||
|
- [ ] **4.8** Add to Zustand stores
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Competitor Analysis Notes
|
||||||
|
|
||||||
|
Features observed in competitor apps (Desire, Kindu, Spicer, Lovewick):
|
||||||
|
|
||||||
|
| Feature | In Our Plan | Notes |
|
||||||
|
|---------|-------------|-------|
|
||||||
|
| Dare Packs/Categories | ✅ | Themed collections |
|
||||||
|
| Intensity Levels | ✅ | 1-5 scale, reuses user preference |
|
||||||
|
| Time Limits | ✅ | Preset options + no limit |
|
||||||
|
| Points/Scoring | ✅ Simple | Just count, not weighted points |
|
||||||
|
| Custom Dares | ✅ Premium | Users create their own |
|
||||||
|
| Decline Option | ✅ | No negative impact |
|
||||||
|
| Dare History | ✅ | Via `sent_dares` table |
|
||||||
|
| Chat per Dare | ✅ | `dare_messages` table |
|
||||||
|
| Proof/Verification | ❌ | Not in v1 (could add photo proof later) |
|
||||||
|
| Daily/Random Dares | ❌ | Not in v1 (could add "random dare" button) |
|
||||||
|
| Dare Rewards | ❌ | Not in v1 (could unlock content) |
|
||||||
|
| Streak Tracking | ❌ | Not in v1 (could add later) |
|
||||||
|
|
||||||
|
### Potential Future Enhancements
|
||||||
|
|
||||||
|
1. **Photo/Video Proof**: Allow recipient to submit proof of completion
|
||||||
|
2. **Random Dare Button**: "Surprise me" feature that picks a random dare
|
||||||
|
3. **Daily Dare Suggestions**: Push notification with suggested dare
|
||||||
|
4. **Streak Tracking**: Consecutive days/weeks with completed dares
|
||||||
|
5. **Achievements/Badges**: Unlock badges for milestones (10 dares, 50 dares, etc.)
|
||||||
|
6. **Couple Leaderboard**: Compare with other couples (opt-in)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions (Resolved)
|
||||||
|
|
||||||
|
| Question | Answer |
|
||||||
|
|----------|--------|
|
||||||
|
| Should dare packs use same `categories` table? | Yes, reuse existing categories |
|
||||||
|
| Can same dare be sent multiple times? | Yes, each is a new instance |
|
||||||
|
| Bulk import for dares in admin? | Not in v1, can add later |
|
||||||
|
| Migration target | Non-prod first, then prod |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Existing schema patterns: `apps/supabase/migrations/20251230000000_v1_initial_schema.sql`
|
||||||
|
- Question packs structure: `question_packs`, `questions` tables
|
||||||
|
- Match/messaging pattern: `matches`, `messages` tables
|
||||||
|
- Admin portal patterns: `apps/admin/src/pages/QuestionPacksPage.tsx`
|
||||||
|
Before Width: | Height: | Size: 755 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 613 KiB |
|
Before Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 646 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 863 KiB |