6 Commits
Author SHA1 Message Date
jjbruntonandClaude Opus 4.5 b0f1700e40 Add match archiving, chat enhancements, and daily limits
- Match archives with swipe-to-archive and unread filtering
- Chat settings sheet and full-screen media modals
- Match notification modal for new matches
- Admin AI settings with model/provider configuration
- Daily response limit feature with recommended questions
- New test coverage for stores, hooks, and components
- Claude Code agent and command configurations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 20:18:34 +00:00
jjbrunton 595d6bd394 Remove partner answered indicator for privacy
Prevents users from deducing partner's negative answers
on the My Answers page when no match exists.
2026-01-09 13:44:04 +00:00
jjbrunton fc515a19b0 Fix Select empty value error in DarePacksPage 2026-01-08 21:39:44 +00:00
jjbrunton b933c3c421 Add dares feature schema and admin portal 2026-01-08 21:37:34 +00:00
jjbrunton aff0bc0888 Add admin pagination and update configs 2026-01-08 21:15:34 +00:00
jjbruntonandClaude Opus 4.5 b60b72a289 Update icons, screenshots, and UX improvements
- Refactor icon picker to use full Ionicons set dynamically
- Add date sort toggle to My Answers screen
- Fix match deduplication and show total count on home
- Clear stale data when switching chats
- Update app store screenshots
- Bump build number to 25

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 19:58:29 +00:00
145 changed files with 10555 additions and 919 deletions

No files matched your search

+32
View File
@@ -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.
+112
View File
@@ -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.
+98
View File
@@ -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.
+89
View File
@@ -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
+87
View File
@@ -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.
+106
View File
@@ -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
+6 -1
View File
@@ -3,5 +3,10 @@
"allow": [
"Bash(wc:*)"
]
}
},
"enabledMcpjsonServers": [
"sauci-prod",
"sauci-non-prod"
],
"enableAllProjectMcpServers": true
}
+1
View File
@@ -3,6 +3,7 @@ node_modules/
.pnp
.pnp.js
tmp/
# Build outputs
dist/
build/
+9
View File
@@ -2,15 +2,24 @@ FROM node:20-alpine
WORKDIR /app
# Copy root package files and workspace configs
COPY package*.json ./
COPY turbo.json ./
# Copy all workspace package.json files
COPY apps/admin/package.json ./apps/admin/
COPY apps/mobile/package.json ./apps/mobile/
COPY apps/supabase/package.json ./apps/supabase/
COPY apps/web/package.json ./apps/web/
COPY packages/shared/package.json ./packages/shared/
# Install all dependencies at root level (workspaces)
RUN npm install
# Copy all source code
COPY . .
# Build shared package
RUN npx turbo run build --filter=@sauci/shared
WORKDIR /app/apps/admin
+2
View File
@@ -23,4 +23,6 @@ services:
- ./vite.config.ts:/app/apps/admin/vite.config.ts
- ./tsconfig.json:/app/apps/admin/tsconfig.json
- ../../packages/shared/src:/app/packages/shared/src
# Preserve node_modules from container (don't override with host)
- /app/node_modules
restart: unless-stopped
-1
View File
@@ -34,7 +34,6 @@
"react-dom": "^18.2.0",
"react-icons": "^4.12.0",
"react-router-dom": "^6.21.0",
"recharts": "^3.6.0",
"sonner": "^1.3.1",
"tailwind-merge": "^2.2.0"
+18
View File
@@ -7,6 +7,8 @@ import { DashboardPage } from '@/pages/DashboardPage';
import { CategoriesPage } from '@/pages/content/CategoriesPage';
import { PacksPage } from '@/pages/content/PacksPage';
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 { UserDetailPage } from '@/pages/users/UserDetailPage';
import { MatchChatPage } from '@/pages/users/MatchChatPage';
@@ -64,6 +66,22 @@ function App() {
</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 */}
<Route
@@ -18,6 +18,7 @@ import {
Home,
Activity,
AlertTriangle,
Zap,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
@@ -47,6 +48,12 @@ const contentNav: NavItem[] = [
icon: <LayoutGrid className="h-5 w-5" />,
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[] = [
+25 -106
View File
@@ -1,48 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { IconType } from 'react-icons';
import {
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 * as Io5Icons from 'react-icons/io5';
import { Button } from './button';
import { Input } from './input';
@@ -51,68 +9,28 @@ interface IconOption {
Icon: IconType;
}
const ICON_OPTIONS: IconOption[] = [
// Relationships & Love
{ name: 'heart-outline', Icon: IoHeartOutline },
{ name: 'heart-half-outline', Icon: IoHeartHalfOutline },
{ name: 'heart-circle-outline', Icon: IoHeartCircleOutline },
/**
* Convert PascalCase component name to kebab-case Ionicon name.
* e.g., "IoHeartOutline" -> "heart-outline"
*/
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
{ name: 'chatbubbles-outline', Icon: IoChatbubblesOutline },
{ name: 'chatbox-outline', Icon: IoChatboxOutline },
{ name: 'mail-outline', Icon: IoMailOutline },
// Romance & Dates
{ name: 'flower-outline', Icon: IoFlowerOutline },
{ name: 'wine-outline', Icon: IoWineOutline },
{ 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 },
];
// Build icon options from all Io5 icons dynamically
const ICON_OPTIONS: IconOption[] = Object.entries(Io5Icons)
.filter(([name]) => name.startsWith('Io'))
.map(([componentName, Icon]) => ({
name: toKebabCase(componentName),
Icon: Icon as IconType,
}))
.sort((a, b) => a.name.localeCompare(b.name));
const ICONS_BY_NAME = ICON_OPTIONS.reduce<Record<string, IconOption>>((acc, option) => {
acc[option.name] = option;
@@ -168,8 +86,9 @@ export function IconPicker({ value, onChange }: IconPickerProps) {
return ICON_OPTIONS.filter((option) => option.name.includes(normalized));
}, [query]);
const SelectedIcon = ICONS_BY_NAME[value]?.Icon;
const buttonLabel = SelectedIcon ? value : value ? 'Legacy icon' : 'Select an icon';
const selectedOption = ICONS_BY_NAME[value];
const SelectedIcon = selectedOption?.Icon;
const buttonLabel = selectedOption ? value : value ? 'Legacy icon' : 'Select an icon';
return (
<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>
);
}
+10
View File
@@ -23,6 +23,16 @@ export interface AiConfig {
classifier_enabled: boolean | null;
classifier_model: string | null;
classifier_prompt: string | null;
heuristics_enabled: boolean | null;
heuristic_min_text_length: number | null;
heuristic_whitelist_max_length: number | null;
heuristic_skip_if_no_alnum: boolean | null;
heuristic_skip_media_without_text: boolean | null;
heuristic_record_reason: boolean | null;
heuristic_use_default_whitelist: boolean | null;
heuristic_use_default_keywords: boolean | null;
heuristic_whitelist: string | null;
heuristic_keyword_triggers: string | null;
updated_at: string | null;
updated_by: string | null;
}
+1
View File
@@ -4,6 +4,7 @@ import { supabase } from '@/config';
export interface AppConfig {
id: string;
answer_gap_threshold: number;
daily_response_limit: number;
updated_at: string | null;
updated_by: string | null;
}
+2 -2
View File
@@ -86,10 +86,10 @@ export const auditedSupabase = {
/**
* Update a single record with audit logging
*/
async update<T extends { id: string }>(
async update<T extends object = Record<string, unknown>>(
table: string,
id: string,
updates: Partial<T>
updates: Partial<T> | Record<string, unknown>
): Promise<SingleResult<T>> {
// Fetch old values before update
let oldValues: Record<string, unknown> | null = null;
+2 -2
View File
@@ -43,8 +43,8 @@ import { useState, useCallback } from 'react';
* ```
*/
export function useEntityForm<
TFormData extends Record<string, unknown>,
TEntity extends Record<string, unknown> = TFormData
TFormData extends object,
TEntity extends object = TFormData
>(
initialFormData: TFormData,
/**
+305 -1
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import type { KeyboardEvent } from 'react';
import { useAiConfig, AiConfig, CouncilGenerator } from '@/hooks/useAiConfig';
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
@@ -10,11 +11,35 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Separator } from '@/components/ui/separator';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Textarea } from '@/components/ui/textarea';
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry, Shield } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry, Shield, X } from 'lucide-react';
import { toast } from 'sonner';
import { format } from 'date-fns';
import { ModelCombobox } from '@/components/ModelCombobox';
type ListField = 'heuristic_whitelist' | 'heuristic_keyword_triggers';
const parseListValue = (value?: string | null): string[] => {
if (!value) return [];
return value
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
};
const appendUniqueItem = (items: string[], value: string): string[] => {
const trimmed = value.trim();
if (!trimmed) return items;
const existing = new Set(items.map((item) => item.toLowerCase()));
if (existing.has(trimmed.toLowerCase())) return items;
return [...items, trimmed];
};
const removeItem = (items: string[], value: string): string[] => {
const target = value.toLowerCase();
return items.filter((item) => item.toLowerCase() !== target);
};
export function AiSettingsPage() {
const { hasPermission, isSuperAdmin } = useAuth();
const { config, loading, error, updateConfig, refetch } = useAiConfig();
@@ -24,6 +49,8 @@ export function AiSettingsPage() {
const [showApiKey, setShowApiKey] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [whitelistInput, setWhitelistInput] = useState('');
const [keywordInput, setKeywordInput] = useState('');
// Permission check - only super admins or those with MANAGE_AI_CONFIG can access
const canManage = isSuperAdmin || hasPermission(PERMISSION_KEYS.MANAGE_AI_CONFIG);
@@ -51,6 +78,16 @@ export function AiSettingsPage() {
classifier_enabled: config.classifier_enabled ?? true,
classifier_model: config.classifier_model || 'openai/gpt-4o',
classifier_prompt: config.classifier_prompt || '',
heuristics_enabled: config.heuristics_enabled ?? false,
heuristic_min_text_length: config.heuristic_min_text_length ?? 12,
heuristic_whitelist_max_length: config.heuristic_whitelist_max_length ?? 30,
heuristic_skip_if_no_alnum: config.heuristic_skip_if_no_alnum ?? true,
heuristic_skip_media_without_text: config.heuristic_skip_media_without_text ?? false,
heuristic_record_reason: config.heuristic_record_reason ?? false,
heuristic_use_default_whitelist: config.heuristic_use_default_whitelist ?? true,
heuristic_use_default_keywords: config.heuristic_use_default_keywords ?? true,
heuristic_whitelist: config.heuristic_whitelist || '',
heuristic_keyword_triggers: config.heuristic_keyword_triggers || '',
});
setHasChanges(false);
}
@@ -61,6 +98,46 @@ export function AiSettingsPage() {
setHasChanges(true);
};
const whitelistItems = parseListValue(formData.heuristic_whitelist ?? '');
const keywordItems = parseListValue(formData.heuristic_keyword_triggers ?? '');
const updateListField = (field: ListField, items: string[]) => {
handleChange(field, items.length > 0 ? items.join('\n') : '');
};
const handleAddListItem = (
field: ListField,
value: string,
clearValue: (next: string) => void
) => {
const current = field === 'heuristic_whitelist' ? whitelistItems : keywordItems;
const nextItems = appendUniqueItem(current, value);
if (nextItems !== current) {
updateListField(field, nextItems);
}
if (value.trim()) {
clearValue('');
}
};
const handleRemoveListItem = (field: ListField, value: string) => {
const current = field === 'heuristic_whitelist' ? whitelistItems : keywordItems;
const nextItems = removeItem(current, value);
updateListField(field, nextItems);
};
const handleListKeyDown = (
event: KeyboardEvent<HTMLInputElement>,
field: ListField,
value: string,
clearValue: (next: string) => void
) => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddListItem(field, value, clearValue);
}
};
const handleSave = async () => {
setIsSaving(true);
try {
@@ -99,6 +176,16 @@ export function AiSettingsPage() {
classifier_enabled: config.classifier_enabled ?? true,
classifier_model: config.classifier_model || 'openai/gpt-4o',
classifier_prompt: config.classifier_prompt || '',
heuristics_enabled: config.heuristics_enabled ?? false,
heuristic_min_text_length: config.heuristic_min_text_length ?? 12,
heuristic_whitelist_max_length: config.heuristic_whitelist_max_length ?? 30,
heuristic_skip_if_no_alnum: config.heuristic_skip_if_no_alnum ?? true,
heuristic_skip_media_without_text: config.heuristic_skip_media_without_text ?? false,
heuristic_record_reason: config.heuristic_record_reason ?? false,
heuristic_use_default_whitelist: config.heuristic_use_default_whitelist ?? true,
heuristic_use_default_keywords: config.heuristic_use_default_keywords ?? true,
heuristic_whitelist: config.heuristic_whitelist || '',
heuristic_keyword_triggers: config.heuristic_keyword_triggers || '',
});
setHasChanges(false);
}
@@ -305,6 +392,223 @@ export function AiSettingsPage() {
Instructions for the AI classifier.
</p>
</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>
</Card>
+39
View File
@@ -28,6 +28,7 @@ export function AppSettingsPage() {
if (config) {
setFormData({
answer_gap_threshold: config.answer_gap_threshold ?? 10,
daily_response_limit: config.daily_response_limit ?? 0,
});
setHasChanges(false);
}
@@ -59,6 +60,7 @@ export function AppSettingsPage() {
if (config) {
setFormData({
answer_gap_threshold: config.answer_gap_threshold ?? 10,
daily_response_limit: config.daily_response_limit ?? 0,
});
setHasChanges(false);
}
@@ -176,6 +178,43 @@ export function AppSettingsPage() {
</div>
</CardContent>
</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>
);
}
+36 -8
View File
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import {
Table,
TableBody,
@@ -34,28 +35,42 @@ const actionColors: Record<string, string> = {
export function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [totalCount, setTotalCount] = useState(0);
useEffect(() => {
const fetchLogs = async () => {
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const { data, error } = await supabase
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
const { data, error, count } = await supabase
.from('audit_logs')
.select('*')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false })
.limit(100);
.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(() => {
fetchLogs();
}, []);
}, [fetchLogs]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
if (loading) {
return (
@@ -141,6 +156,19 @@ export function AuditLogsPage() {
</TableBody>
</Table>
</div>
{logs.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div>
);
}
+38 -1
View File
@@ -4,6 +4,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Textarea } from '@/components/ui/textarea';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
@@ -87,6 +89,9 @@ export function FeedbackPage() {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
// Detail Dialog State
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
@@ -140,6 +145,11 @@ export function FeedbackPage() {
fetchFeedback();
}, [fetchFeedback]);
useEffect(() => {
setPage(1);
}, [search, typeFilter, statusFilter]);
const handleOpenDetail = (item: Feedback) => {
setSelectedFeedback(item);
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 config = feedbackTypeConfig[type];
return (
@@ -295,7 +319,7 @@ export function FeedbackPage() {
</TableCell>
</TableRow>
) : (
filteredFeedback.map((item) => (
paginatedFeedback.map((item) => (
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleOpenDetail(item)}>
<TableCell>{getTypeBadge(item.type)}</TableCell>
<TableCell>
@@ -329,6 +353,19 @@ export function FeedbackPage() {
</Table>
</div>
{filteredFeedback.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
{/* Detail Dialog */}
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
+72 -15
View File
@@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { PaginationControls } from '@/components/ui/pagination';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
@@ -65,10 +66,16 @@ export function FlaggedMessagesPage() {
// AI Flagged state
const [aiMessages, setAiMessages] = useState<FlaggedMessage[]>([]);
const [aiLoading, setAiLoading] = useState(true);
const [aiPage, setAiPage] = useState(1);
const [aiPageSize, setAiPageSize] = useState(25);
const [aiTotal, setAiTotal] = useState(0);
// User Reports state
const [userReports, setUserReports] = useState<UserReport[]>([]);
const [reportsLoading, setReportsLoading] = useState(true);
const [reportsPage, setReportsPage] = useState(1);
const [reportsPageSize, setReportsPageSize] = useState(25);
const [reportsTotal, setReportsTotal] = useState(0);
// Shared state
const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null);
@@ -82,30 +89,38 @@ export function FlaggedMessagesPage() {
const [describingInline, setDescribingInline] = useState<string | null>(null);
// Fetch AI flagged messages
const fetchAiMessages = async () => {
const fetchAiMessages = useCallback(async () => {
setAiLoading(true);
try {
const { data, error } = await supabase
const from = (aiPage - 1) * aiPageSize;
const to = from + aiPageSize - 1;
const { data, error, count } = await supabase
.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')
.order('created_at', { ascending: false });
.order('created_at', { ascending: false })
.range(from, to);
if (error) throw error;
setAiMessages(data || []);
setAiTotal(count || 0);
} catch (error) {
console.error('Failed to load flagged messages:', error);
toast.error('Failed to load messages');
} finally {
setAiLoading(false);
}
};
}, [aiPage, aiPageSize]);
// Fetch user reports
const fetchUserReports = async () => {
const fetchUserReports = useCallback(async () => {
setReportsLoading(true);
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')
.select(`
id,
@@ -116,24 +131,40 @@ export function FlaggedMessagesPage() {
created_at,
message:messages(id, user_id, match_id, content, media_path, media_type),
reporter_profile:profiles!reporter_id(name, email)
`)
`, { count: 'exact' })
.eq('status', 'pending')
.order('created_at', { ascending: false });
.order('created_at', { ascending: false })
.range(from, to);
if (error) throw error;
setUserReports((data || []) as unknown as UserReport[]);
setReportsTotal(count || 0);
} catch (error) {
console.error('Failed to load user reports:', error);
toast.error('Failed to load reports');
} finally {
setReportsLoading(false);
}
};
}, [reportsPage, reportsPageSize]);
useEffect(() => {
fetchAiMessages();
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
const fetchDecryptedMedia = async (messageId: string) => {
@@ -296,8 +327,8 @@ export function FlaggedMessagesPage() {
if (error) throw error;
toast.success('Message marked as safe');
setAiMessages(prev => prev.filter(m => m.id !== messageId));
if (selectedMessage?.id === messageId) setSelectedMessage(null);
fetchAiMessages();
} catch (error) {
toast.error('Failed to update status');
} finally {
@@ -316,8 +347,8 @@ export function FlaggedMessagesPage() {
if (error) throw error;
toast.success('Report dismissed');
setUserReports(prev => prev.filter(r => r.id !== reportId));
if (selectedReport?.id === reportId) setSelectedReport(null);
fetchUserReports();
} catch (error) {
toast.error('Failed to dismiss report');
} finally {
@@ -336,8 +367,8 @@ export function FlaggedMessagesPage() {
if (error) throw error;
toast.success('Report marked as reviewed');
setUserReports(prev => prev.filter(r => r.id !== reportId));
if (selectedReport?.id === reportId) setSelectedReport(null);
fetchUserReports();
} catch (error) {
toast.error('Failed to update report');
} finally {
@@ -491,6 +522,19 @@ export function FlaggedMessagesPage() {
</TableBody>
</Table>
</Card>
{aiTotal > 0 && (
<PaginationControls
page={aiPage}
pageSize={aiPageSize}
totalCount={aiTotal}
onPageChange={setAiPage}
onPageSizeChange={(size) => {
setAiPage(1);
setAiPageSize(size);
}}
/>
)}
</TabsContent>
<TabsContent value="user-reports" className="space-y-4">
@@ -579,6 +623,19 @@ export function FlaggedMessagesPage() {
</TableBody>
</Table>
</Card>
{reportsTotal > 0 && (
<PaginationControls
page={reportsPage}
pageSize={reportsPageSize}
totalCount={reportsTotal}
onPageChange={setReportsPage}
onPageSizeChange={(size) => {
setReportsPage(1);
setReportsPageSize(size);
}}
/>
)}
</TabsContent>
</Tabs>
+101 -23
View File
@@ -1,10 +1,13 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Switch } from '@/components/ui/switch';
import {
Table,
@@ -64,6 +67,10 @@ export function RedemptionCodesPage() {
const [codes, setCodes] = useState<RedemptionCode[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
// Add Code State
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
@@ -79,41 +86,60 @@ export function RedemptionCodesPage() {
const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null);
const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]);
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);
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')
.select('*')
.select('*', { count: 'exact' })
.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;
setCodes(data || []);
setTotalCount(count || 0);
} catch (error: any) {
console.error('Failed to load codes:', error);
toast.error("Failed to load redemption codes");
} finally {
setLoading(false);
}
};
}, [page, pageSize, search]);
const fetchRedemptions = async (codeId: string) => {
const fetchRedemptions = useCallback(async (codeId: string) => {
setLoadingRedemptions(true);
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')
.select('id, user_id, redeemed_at')
.select('id, user_id, redeemed_at', { count: 'exact' })
.eq('code_id', codeId)
.order('redeemed_at', { ascending: false });
.order('redeemed_at', { ascending: false })
.range(from, to);
if (redemptionError) throw redemptionError;
setRedemptionsTotal(count || 0);
if (redemptionData && redemptionData.length > 0) {
const userIds = redemptionData.map(r => r.user_id);
const { data: profileData } = await supabase
@@ -135,7 +161,36 @@ export function RedemptionCodesPage() {
} finally {
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 () => {
if (!formData.code.trim()) {
@@ -226,17 +281,12 @@ export function RedemptionCodesPage() {
const handleViewRedemptions = (code: RedemptionCode) => {
setViewingCode(code);
fetchRedemptions(code.id);
setRedemptionsPage(1);
};
const filteredCodes = codes.filter(code => {
if (!search) return true;
const query = search.toLowerCase();
return (
code.code.toLowerCase().includes(query) ||
code.description?.toLowerCase().includes(query)
);
});
const filteredCodes = codes;
const getStatusBadge = (code: RedemptionCode) => {
if (!code.is_active) {
@@ -440,6 +490,20 @@ export function RedemptionCodesPage() {
</Table>
</div>
{filteredCodes.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
{/* View Redemptions Dialog */}
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
<DialogContent className="sm:max-w-[500px]">
@@ -486,7 +550,21 @@ export function RedemptionCodesPage() {
</Table>
)}
</div>
{redemptionsTotal > 0 && (
<PaginationControls
page={redemptionsPage}
pageSize={redemptionsPageSize}
totalCount={redemptionsTotal}
onPageChange={setRedemptionsPage}
onPageSizeChange={(size) => {
setRedemptionsPage(1);
setRedemptionsPageSize(size);
}}
/>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
</DialogFooter>
</DialogContent>
@@ -6,6 +6,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { PaginationControls } from '@/components/ui/pagination';
import {
Table,
TableBody,
@@ -121,6 +122,18 @@ export function UserActivityPage() {
const [matches, setMatches] = useState<MatchActivity[]>([]);
const [messages, setMessages] = useState<MessageActivity[]>([]);
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({
responses: true,
matches: true,
@@ -132,7 +145,10 @@ export function UserActivityPage() {
const fetchResponses = useCallback(async () => {
setLoading(prev => ({ ...prev, responses: true }));
try {
const { data, error } = await supabase
const from = (responsesPage - 1) * responsesPageSize;
const to = from + responsesPageSize - 1;
const { data, error, count } = await supabase
.from('responses')
.select(`
id, user_id, answer, created_at,
@@ -141,12 +157,14 @@ export function UserActivityPage() {
id, text,
pack:question_packs(name)
)
`)
`, { count: 'exact' })
.order('created_at', { ascending: false })
.limit(50);
.range(from, to);
if (error) throw error;
setResponsesTotal(count || 0);
// Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations)
const transformed: ResponseActivity[] = (data || []).map((item: any) => ({
id: item.id,
@@ -167,23 +185,28 @@ export function UserActivityPage() {
} finally {
setLoading(prev => ({ ...prev, responses: false }));
}
}, []);
}, [responsesPage, responsesPageSize]);
// Fetch matches with couple profiles
const fetchMatches = useCallback(async () => {
setLoading(prev => ({ ...prev, matches: true }));
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')
.select(`
id, couple_id, match_type, is_new, created_at,
question:questions(id, text)
`)
`, { count: 'exact' })
.order('created_at', { ascending: false })
.limit(50);
.range(from, to);
if (matchesError) throw matchesError;
setMatchesTotal(count || 0);
// Get unique couple IDs and fetch profiles
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
if (coupleIds.length > 0) {
@@ -223,14 +246,17 @@ export function UserActivityPage() {
} finally {
setLoading(prev => ({ ...prev, matches: false }));
}
}, []);
}, [matchesPage, matchesPageSize]);
// Fetch messages
const fetchMessages = useCallback(async () => {
setLoading(prev => ({ ...prev, messages: true }));
try {
const from = (messagesPage - 1) * messagesPageSize;
const to = from + messagesPageSize - 1;
// Fetch messages with match data
const { data: messagesData, error: messagesError } = await supabase
const { data: messagesData, error: messagesError, count } = await supabase
.from('messages')
.select(`
id, user_id, content, media_path, media_type, created_at,
@@ -238,12 +264,14 @@ export function UserActivityPage() {
id,
question:questions(text)
)
`)
`, { count: 'exact' })
.order('created_at', { ascending: false })
.limit(50);
.range(from, to);
if (messagesError) throw messagesError;
setMessagesTotal(count || 0);
// Get unique user IDs and fetch profiles
const userIds = [...new Set((messagesData || []).map((m: any) => m.user_id))];
let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {};
@@ -283,27 +311,31 @@ export function UserActivityPage() {
} finally {
setLoading(prev => ({ ...prev, messages: false }));
}
}, []);
}, [messagesPage, messagesPageSize]);
// Fetch signups
const fetchSignups = useCallback(async () => {
setLoading(prev => ({ ...prev, signups: true }));
try {
const { data, error } = await supabase
const from = (signupsPage - 1) * signupsPageSize;
const to = from + signupsPageSize - 1;
const { data, error, count } = await supabase
.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 })
.limit(50);
.range(from, to);
if (error) throw error;
setSignups(data || []);
setSignupsTotal(count || 0);
} catch (error) {
console.error('Failed to load signups:', error);
toast.error("Failed to load signups");
} finally {
setLoading(prev => ({ ...prev, signups: false }));
}
}, []);
}, [signupsPage, signupsPageSize]);
// Real-time subscriptions
const { status: responsesStatus } = useRealtimeSubscription({
@@ -338,6 +370,34 @@ export function UserActivityPage() {
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
const getCurrentRealtimeStatus = () => {
switch (activeTab) {
@@ -461,6 +521,19 @@ export function UserActivityPage() {
</TableBody>
</Table>
</div>
{responsesTotal > 0 && (
<PaginationControls
page={responsesPage}
pageSize={responsesPageSize}
totalCount={responsesTotal}
onPageChange={setResponsesPage}
onPageSizeChange={(size) => {
setResponsesPage(1);
setResponsesPageSize(size);
}}
/>
)}
</TabsContent>
{/* Matches Tab */}
@@ -535,6 +608,19 @@ export function UserActivityPage() {
</TableBody>
</Table>
</div>
{matchesTotal > 0 && (
<PaginationControls
page={matchesPage}
pageSize={matchesPageSize}
totalCount={matchesTotal}
onPageChange={setMatchesPage}
onPageSizeChange={(size) => {
setMatchesPage(1);
setMatchesPageSize(size);
}}
/>
)}
</TabsContent>
{/* Messages Tab */}
@@ -593,6 +679,19 @@ export function UserActivityPage() {
</TableBody>
</Table>
</div>
{messagesTotal > 0 && (
<PaginationControls
page={messagesPage}
pageSize={messagesPageSize}
totalCount={messagesTotal}
onPageChange={setMessagesPage}
onPageSizeChange={(size) => {
setMessagesPage(1);
setMessagesPageSize(size);
}}
/>
)}
</TabsContent>
{/* Signups Tab */}
@@ -666,6 +765,19 @@ export function UserActivityPage() {
</TableBody>
</Table>
</div>
{signupsTotal > 0 && (
<PaginationControls
page={signupsPage}
pageSize={signupsPageSize}
totalCount={signupsTotal}
onPageChange={setSignupsPage}
onPageSizeChange={(size) => {
setSignupsPage(1);
setSignupsPageSize(size);
}}
/>
)}
</TabsContent>
</Tabs>
</div>
+34 -1
View File
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Checkbox } from '@/components/ui/checkbox';
import {
@@ -58,6 +59,8 @@ export function AdminsPage() {
const [admins, setAdmins] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
// Add Admin State
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -509,7 +529,7 @@ export function AdminsPage() {
</TableCell>
</TableRow>
) : (
filteredAdmins.map((admin) => (
paginatedAdmins.map((admin) => (
<TableRow key={admin.user_id}>
<TableCell>
<div className="flex items-center gap-3">
@@ -590,6 +610,19 @@ export function AdminsPage() {
</Table>
</div>
{filteredAdmins.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
{/* Edit Permissions Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
@@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { IconPicker, IconPreview } from '@/components/ui/icon-picker';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import {
Dialog,
DialogContent,
@@ -53,6 +54,9 @@ export function CategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
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
const form = useEntityForm<CategoryFormData, Category>(
@@ -71,25 +75,34 @@ export function CategoriesPage() {
const fetchCategories = useCallback(async () => {
setLoading(true);
try {
// Fetch categories with pack count
const { data: cats, error } = await supabase
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
const { data: cats, error, count } = await supabase
.from('categories')
.select('*')
.order('sort_order', { ascending: true });
.select('*', { count: 'exact' })
.order('sort_order', { ascending: true })
.range(from, to);
if (error) throw error;
// Fetch pack counts per category
setTotalCount(count || 0);
const categoryIds = (cats || []).map(c => c.id);
const packCounts: Record<string, number> = {};
if (categoryIds.length > 0) {
const { data: packs } = await supabase
.from('question_packs')
.select('category_id');
.select('category_id')
.in('category_id', categoryIds);
const packCounts: Record<string, number> = {};
packs?.forEach(p => {
if (p.category_id) {
packCounts[p.category_id] = (packCounts[p.category_id] || 0) + 1;
}
});
}
setCategories(
(cats || []).map(c => ({
@@ -103,12 +116,19 @@ export function CategoriesPage() {
} finally {
setLoading(false);
}
}, []);
}, [page, pageSize]);
useEffect(() => {
fetchCategories();
}, [fetchCategories]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
// =============================================================================
// Handlers
// =============================================================================
@@ -143,7 +163,7 @@ export function CategoriesPage() {
} else {
const { error } = await auditedSupabase.insert('categories', {
...data,
sort_order: categories.length,
sort_order: totalCount,
});
if (error) throw error;
toast.success('Category created');
@@ -406,6 +426,19 @@ export function CategoriesPage() {
</div>
)}
{categories.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
{/* AI Dialog */}
<AiGeneratorDialog
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>
);
}
+551
View File
@@ -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>
);
}
+45 -8
View File
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
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 { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
@@ -60,6 +61,9 @@ export function PacksPage() {
const [extractTopicsPack, setExtractTopicsPack] = useState<QuestionPack | null>(null);
const [allTopics, setAllTopics] = useState<Topic[]>([]);
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
const form = useEntityForm<PackFormData, QuestionPack>(
@@ -98,30 +102,39 @@ export function PacksPage() {
setCategory(cat);
}
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
// Fetch packs
let query = supabase
.from('question_packs')
.select('*')
.order('sort_order', { ascending: true });
.select('*', { count: 'exact' })
.order('sort_order', { ascending: true })
.range(from, to);
if (categoryId) {
query = query.eq('category_id', categoryId);
}
const { data: packData, error } = await query;
const { data: packData, error, count } = await query;
if (error) throw error;
setTotalCount(count || 0);
// Fetch question counts
const packIds = (packData || []).map(p => p.id);
const questionCounts: Record<string, number> = {};
if (packIds.length > 0) {
const { data: questions } = await supabase
.from('questions')
.select('pack_id')
.in('pack_id', packIds);
const questionCounts: Record<string, number> = {};
questions?.forEach(q => {
questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1;
});
}
// Fetch all topics
const { data: topicsData } = await supabase
@@ -131,13 +144,17 @@ export function PacksPage() {
setAllTopics(topicsData || []);
// Fetch pack_topics relationships
const { data: packTopicsData } = await supabase
let packTopicsData: any[] = [];
if (packIds.length > 0) {
const { data } = await supabase
.from('pack_topics')
.select('pack_id, topic_id, topics(id, name)')
.in('pack_id', packIds);
packTopicsData = data || [];
}
const packTopicsMap: Record<string, Topic[]> = {};
packTopicsData?.forEach((pt: any) => {
packTopicsData.forEach((pt: any) => {
if (!packTopicsMap[pt.pack_id]) {
packTopicsMap[pt.pack_id] = [];
}
@@ -159,12 +176,19 @@ export function PacksPage() {
} finally {
setLoading(false);
}
}, [categoryId]);
}, [categoryId, page, pageSize]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
// =============================================================================
// Handlers
// =============================================================================
@@ -211,7 +235,7 @@ export function PacksPage() {
is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit,
category_id: categoryId || null,
sort_order: packs.length,
sort_order: totalCount,
})
.select('id')
.single();
@@ -535,6 +559,19 @@ export function PacksPage() {
))}
</div>
)}
{packs.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div>
);
}
+52 -14
View File
@@ -9,6 +9,7 @@ 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,
@@ -70,6 +71,9 @@ export function QuestionsPage() {
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<{
@@ -100,14 +104,19 @@ export function QuestionsPage() {
setPack(packData);
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
// Fetch questions
const { data: questionData, error } = await supabase
const { data: questionData, error, count } = await supabase
.from('questions')
.select('*')
.select('*', { count: 'exact' })
.eq('pack_id', packId)
.order('created_at', { ascending: true });
.order('created_at', { ascending: true })
.range(from, to);
if (error) throw error;
setTotalCount(count || 0);
setQuestions(questionData || []);
} catch (error) {
toast.error('Failed to load questions');
@@ -115,33 +124,49 @@ export function QuestionsPage() {
} finally {
setLoading(false);
}
}, [packId]);
}, [packId, page, pageSize]);
// Real-time subscription for questions in this pack
const { status: realtimeStatus } = useRealtimeSubscription<Question>({
table: 'questions',
filter: packId ? `pack_id=eq.${packId}` : undefined,
enabled: !!packId,
onInsert: useCallback((newQuestion: Question) => {
setQuestions(prev => [...prev, newQuestion]);
}, []),
onUpdate: useCallback(({ new: updated }: { old: Question; new: Question }) => {
setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q));
}, []),
onInsert: useCallback(() => {
fetchData();
}, [fetchData]),
onUpdate: useCallback(() => {
fetchData();
}, [fetchData]),
onDelete: useCallback((deleted: Question) => {
setQuestions(prev => prev.filter(q => q.id !== deleted.id));
setSelectedIds(prev => {
const next = new Set(prev);
next.delete(deleted.id);
return next;
});
}, []),
fetchData();
}, [fetchData]),
});
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 = () => {
setEditingQuestion(null);
setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] });
@@ -319,7 +344,7 @@ export function QuestionsPage() {
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
</div>
<p className="text-muted-foreground">
{questions.length} question{questions.length !== 1 ? 's' : ''} in this pack
{totalCount} question{totalCount !== 1 ? 's' : ''} in this pack
</p>
</div>
<div className="flex gap-2">
@@ -615,7 +640,7 @@ export function QuestionsPage() {
/>
</TableCell>
<TableCell className="font-medium text-muted-foreground">
{index + 1}
{(page - 1) * pageSize + index + 1}
</TableCell>
<TableCell>
<span className="line-clamp-2">{question.text}</span>
@@ -689,6 +714,19 @@ export function QuestionsPage() {
</div>
)
}
{questions.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div >
);
}
+68 -28
View File
@@ -12,6 +12,7 @@ import { format, isToday, isYesterday } from 'date-fns';
import { cn } from '@/lib/utils';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { PaginationControls } from '@/components/ui/pagination';
interface Profile {
id: string;
@@ -331,25 +332,11 @@ export function MatchChatPage() {
const [match, setMatch] = useState<Match | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [totalCount, setTotalCount] = useState(0);
// Real-time subscription for messages in this chat
const { status: realtimeStatus } = useRealtimeSubscription<Message>({
table: 'messages',
filter: matchId ? `match_id=eq.${matchId}` : undefined,
enabled: !!matchId,
onInsert: useCallback((newMessage: Message) => {
setMessages(prev => [...prev, newMessage]);
}, []),
onUpdate: useCallback(({ new: updated }: { old: Message; new: Message }) => {
setMessages(prev => prev.map(m => m.id === updated.id ? updated : m));
}, []),
onDelete: useCallback((deleted: Message) => {
setMessages(prev => prev.filter(m => m.id !== deleted.id));
}, []),
});
useEffect(() => {
const fetchData = async () => {
const fetchData = useCallback(async () => {
if (!userId || !matchId) return;
setLoading(true);
@@ -373,6 +360,8 @@ export function MatchChatPage() {
.single();
setPartner(partnerData);
} else {
setPartner(null);
}
// Fetch match with question
@@ -387,23 +376,59 @@ export function MatchChatPage() {
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 });
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
setMessages(messageData || []);
// 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
const { status: realtimeStatus } = useRealtimeSubscription<Message>({
table: 'messages',
filter: matchId ? `match_id=eq.${matchId}` : undefined,
enabled: !!matchId,
onInsert: useCallback(() => {
fetchData();
}, [userId, matchId]);
}, [fetchData]),
onUpdate: useCallback(() => {
fetchData();
}, [fetchData]),
onDelete: useCallback(() => {
fetchData();
}, [fetchData]),
});
useEffect(() => {
fetchData();
}, [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 => {
if (senderId === user?.id) return user;
@@ -497,7 +522,7 @@ export function MatchChatPage() {
<Card className="min-h-[400px]">
<CardHeader className="py-4 border-b">
<CardTitle className="text-sm font-medium">
Messages ({messages.length})
Messages ({totalCount})
</CardTitle>
</CardHeader>
<CardContent className="p-4">
@@ -580,6 +605,21 @@ export function MatchChatPage() {
})}
</div>
)}
{totalCount > 0 && (
<div className="mt-4">
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
</div>
)}
</CardContent>
</Card>
</div>
+101 -14
View File
@@ -8,6 +8,7 @@ import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -237,6 +238,15 @@ export function UserDetailPage() {
const [responses, setResponses] = useState<Response[]>([]);
const [matches, setMatches] = useState<Match[]>([]);
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 canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
@@ -308,44 +318,121 @@ export function UserDetailPage() {
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[]);
setResponsesTotal(responsesCount || 0);
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);
if (matchIds.length > 0) {
const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds);
const messageCounts: Record<string, number> = {};
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[]);
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) {
console.error("Error fetching media messages:", mediaError);
setMediaMessages([]);
setMediaTotal(0);
} else {
setMediaMessages(mediaMessagesData || []);
setMediaTotal(mediaCount || 0);
}
} else {
setMediaMessages([]);
setMediaTotal(0);
}
} else {
setMatches([]);
setMatchesTotal(0);
setMediaMessages([]);
}
} else {
setMatches([]);
setMediaMessages([]);
setMediaTotal(0);
}
} catch (error) {
console.error('Failed to load user data:', error);
} finally {
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: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: 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 (!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 defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
<TabsList>
{canViewResponses && <TabsTrigger value="responses">Responses ({responses.length})</TabsTrigger>}
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matches.length})</TabsTrigger>}
{canViewMedia && <TabsTrigger value="media">Media ({mediaMessages.length})</TabsTrigger>}
{canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
{canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
</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>}
{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>}
{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="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">
{message.media_type === 'video' ? (
<AdminDecryptedVideo messageId={message.id} />
) : (
<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>
</div>
);
+36 -4
View File
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
@@ -46,16 +47,20 @@ export function UsersPage() {
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filteredProfiles, setFilteredProfiles] = useState<Profile[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const fetchProfiles = useCallback(async () => {
setLoading(true);
try {
// 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');
if (error) throw error;
const profilesData = (profilesDataRaw || []) as Profile[];
// Fetch storage usage per user from storage.objects
const { data: storageData } = await supabase
.rpc('get_user_storage_usage');
@@ -70,7 +75,7 @@ export function UsersPage() {
// Build a map of couple_id -> users with premium in that couple
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
(profilesData || []).forEach(profile => {
profilesData.forEach(profile => {
if (profile.couple_id) {
if (!couplePartners[profile.couple_id]) {
couplePartners[profile.couple_id] = [];
@@ -83,7 +88,7 @@ export function UsersPage() {
});
// Merge storage data and partner premium status with profiles
const profilesWithExtras = (profilesData || []).map(profile => {
const profilesWithExtras = profilesData.map(profile => {
// Check if partner has premium
let partnerIsPremium = false;
if (profile.couple_id && couplePartners[profile.couple_id]) {
@@ -134,6 +139,7 @@ export function UsersPage() {
}, [fetchProfiles]);
useEffect(() => {
setPage(1);
if (!search.trim()) {
setFilteredProfiles(profiles);
return;
@@ -150,6 +156,19 @@ export function UsersPage() {
);
}, [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) {
return (
<div className="space-y-6">
@@ -209,7 +228,7 @@ export function UsersPage() {
</TableCell>
</TableRow>
) : (
filteredProfiles.map((profile) => (
paginatedProfiles.map((profile) => (
<TableRow key={profile.id}>
<TableCell>
<Link to={`/users/${profile.id}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity">
@@ -294,6 +313,19 @@ export function UsersPage() {
</TableBody>
</Table>
</div>
{filteredProfiles.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div>
);
}
+1 -1
View File
@@ -93,7 +93,7 @@ android {
applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 17
versionCode 21
versionName "1.0.0"
}
signingConfigs {
+2 -2
View File
@@ -16,7 +16,7 @@
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.sauci.app",
"buildNumber": "24",
"buildNumber": "25",
"usesAppleSignIn": true,
"infoPlist": {
"UIBackgroundModes": [
@@ -176,7 +176,7 @@
"backgroundColor": "#1a1a2e"
},
"package": "com.sauci.app",
"versionCode": 20,
"versionCode": 21,
"googleServicesFile": "./google-services.json",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
+53 -137
View File
@@ -10,8 +10,9 @@ import { supabase } from "../../src/lib/supabase";
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal";
import { Events } from "../../src/lib/analytics";
import type { MatchWithQuestion } from "../../src/types";
import type { MatchWithQuestion, AnswerType } from "../../src/types";
import type { Database } from "../../src/types/supabase";
type Message = Database["public"]["Tables"]["messages"]["Row"];
@@ -143,8 +144,7 @@ export default function AppLayout() {
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
const { fetchEnabledPacks } = usePacksStore();
const { initializeRevenueCat } = useSubscriptionStore();
const [matchNotification, setMatchNotification] = useState<MatchWithQuestion | null>(null);
const matchModalAnim = useRef(new Animated.Value(0)).current;
const [matchNotification, setMatchNotification] = useState<MatchNotificationData | null>(null);
const messageToastAnim = useRef(new Animated.Value(-100)).current;
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isLocked, setIsLocked] = useState(false);
@@ -381,10 +381,42 @@ export default function AppLayout() {
Events.milestoneMatch(currentMatchCount + 1);
}
// Add to store and show notification
// Add to store
addMatch(matchWithQuestion);
setMatchNotification(matchWithQuestion);
Events.matchCreated(matchWithQuestion.match_type || "unknown");
// Fetch individual responses to show what each person said
const { data: responses } = await supabase
.from("responses")
.select("user_id, answer")
.eq("question_id", matchWithQuestion.question_id)
.eq("couple_id", user.couple_id);
let userResponse: AnswerType | undefined;
let partnerResponse: AnswerType | undefined;
if (responses && responses.length >= 2) {
for (const r of responses) {
if (r.user_id === user.id) {
userResponse = r.answer as AnswerType;
} else {
partnerResponse = r.answer as AnswerType;
}
}
}
// Get partner name from auth store
const { partner } = useAuthStore.getState();
// Show notification with response data
setMatchNotification({
id: matchWithQuestion.id,
match_type: matchWithQuestion.match_type,
question: matchWithQuestion.question,
userResponse,
partnerResponse,
partnerName: partner?.name,
});
}
}
)
@@ -509,27 +541,17 @@ export default function AppLayout() {
};
}, [lastMessage]);
// Animate match modal when notification changes
useEffect(() => {
if (matchNotification) {
Animated.spring(matchModalAnim, {
toValue: 1,
useNativeDriver: true,
tension: 80,
friction: 10,
}).start();
}
}, [matchNotification, matchModalAnim]);
const dismissNotification = useCallback(() => {
Animated.timing(matchModalAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}).start(() => {
setMatchNotification(null);
});
}, [matchModalAnim]);
}, []);
const handleMatchChat = useCallback(() => {
if (matchNotification) {
const matchId = matchNotification.id;
setMatchNotification(null);
router.push(`/(app)/chat/${matchId}`);
}
}, [matchNotification, router]);
const dismissMessageToast = useCallback(() => {
Animated.timing(messageToastAnim, {
@@ -786,53 +808,13 @@ export default function AppLayout() {
})}
</Animated.View>
{/* Match Notification Overlay - Using Animated.View instead of Modal for Android compatibility */}
{matchNotification && (
<Animated.View
style={[
styles.matchOverlay,
{
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>
)}
{/* Match Notification Modal - Premium styling with response details */}
<MatchNotificationModal
visible={!!matchNotification}
match={matchNotification}
onDismiss={dismissNotification}
onChat={handleMatchChat}
/>
{/* Message Toast Notification */}
{lastMessage && (
@@ -875,72 +857,6 @@ const styles = StyleSheet.create({
alignItems: "center",
backgroundColor: colors.background,
},
matchOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
elevation: 9999,
},
matchOverlayBackground: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
},
modalContent: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
padding: spacing.xl,
alignItems: 'center',
width: '90%',
maxWidth: 340,
borderWidth: 1,
borderColor: colors.glass.border,
...shadows.lg,
},
modalHeader: {
alignItems: 'center',
marginBottom: spacing.lg,
},
modalTitle: {
...typography.title1,
color: colors.text,
marginTop: spacing.md,
},
modalSubtitle: {
...typography.body,
color: colors.textSecondary,
marginBottom: spacing.sm,
},
modalQuestion: {
...typography.headline,
color: colors.text,
textAlign: 'center',
marginBottom: spacing.lg,
},
matchTypeBadge: {
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
marginBottom: spacing.lg,
},
matchTypeText: {
...typography.caption1,
color: colors.primary,
fontWeight: '600',
},
dismissButton: {
backgroundColor: colors.primary,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.lg,
minWidth: 120,
},
dismissButtonText: {
...typography.headline,
color: colors.text,
textAlign: 'center',
},
messageToast: {
position: 'absolute',
top: Platform.OS === 'ios' ? 50 : 20,
+2 -2
View File
@@ -22,7 +22,7 @@ const ACCENT_COLOR = colors.primary;
export default function HomeScreen() {
const { user, partner, couple } = useAuthStore();
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
const { matches, newMatchesCount, totalCount, fetchMatches } = useMatchStore();
const { enabledPackIds, fetchPacks } = usePacksStore();
const { width } = useWindowDimensions();
@@ -146,7 +146,7 @@ export default function HomeScreen() {
color={matches.length > 0 ? colors.text : ACCENT_COLOR}
/>
</View>
<Text style={styles.statNumber}>{matches.length}</Text>
<Text style={styles.statNumber}>{totalCount ?? matches.length}</Text>
<Text style={styles.statLabel}>Matches</Text>
</View>
</TouchableOpacity>
+152 -4
View File
@@ -18,6 +18,7 @@ import Animated, {
} from "react-native-reanimated";
import { BlurView } from "expo-blur";
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { SwipeableMatchItem } from "../../src/components/matches";
import { useAmbientOrbAnimation } from "../../src/hooks";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
import { MatchesTutorial } from "../../src/components/tutorials";
@@ -37,7 +38,23 @@ const HEADER_SCROLL_DISTANCE = 100;
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
export default function MatchesScreen() {
const { matches, fetchMatches, markAllAsSeen, isLoading, hasMore, isLoadingMore, totalCount } = useMatchStore();
const {
matches,
fetchMatches,
markAllAsSeen,
isLoading,
hasMore,
isLoadingMore,
totalCount,
// Archive state and methods
archivedMatches,
showArchived,
toggleShowArchived,
archiveMatch,
unarchiveMatch,
isLoadingArchived,
fetchArchivedMatches,
} = useMatchStore();
const { user } = useAuthStore();
const router = useRouter();
const { width } = useWindowDimensions();
@@ -150,8 +167,20 @@ export default function MatchesScreen() {
const isYesYes = item.match_type === "yes_yes";
const handleArchive = async () => {
if (showArchived) {
await unarchiveMatch(item.id);
} else {
await archiveMatch(item.id);
}
};
return (
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
<SwipeableMatchItem
onArchive={handleArchive}
isArchived={showArchived}
>
<TouchableOpacity
onPress={() => router.push(`/chat/${item.id}`)}
activeOpacity={0.8}
@@ -228,6 +257,7 @@ export default function MatchesScreen() {
<View style={styles.cardPremiumBorder} pointerEvents="none" />
</View>
</TouchableOpacity>
</SwipeableMatchItem>
</Animated.View>
);
};
@@ -296,7 +326,7 @@ export default function MatchesScreen() {
</View>
<AnimatedFlatList
data={matches}
data={showArchived ? archivedMatches : matches}
renderItem={renderItem}
keyExtractor={(item: any) => item.id}
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
@@ -328,15 +358,86 @@ export default function MatchesScreen() {
{/* Decorative separator */}
<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 */}
<View style={styles.countBadgePremium}>
<Ionicons name="heart" size={12} color={ACCENT} />
<Text style={styles.countTextPremium}>{totalCount ?? matches.length} {(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'}</Text>
<Ionicons name={showArchived ? "archive" : "heart"} size={12} color={ACCENT} />
<Text style={styles.countTextPremium}>
{showArchived
? `${archivedMatches.length} ARCHIVED`
: `${totalCount ?? matches.length} ${(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'}`
}
</Text>
</View>
</View>
</Animated.View>
}
ListEmptyComponent={
showArchived ? (
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
>
{/* Archive icon */}
<View style={styles.emptyIconContainer}>
<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}
@@ -372,6 +473,7 @@ export default function MatchesScreen() {
Start Swiping
</GlassButton>
</Animated.View>
)
}
/>
</View>
@@ -478,6 +580,36 @@ const styles = StyleSheet.create({
color: colors.text,
textAlign: 'center',
},
// Filter toggle
filterContainer: {
flexDirection: 'row',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: radius.lg,
padding: 4,
marginBottom: spacing.md,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
},
filterTab: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
gap: spacing.xs,
},
filterTabActive: {
backgroundColor: `${ACCENT_RGBA}0.15)`,
},
filterTabText: {
...typography.caption1,
color: colors.textTertiary,
fontWeight: '500',
},
filterTabTextActive: {
color: ACCENT,
fontWeight: '600',
},
countBadgePremium: {
flexDirection: 'row',
alignItems: 'center',
@@ -628,6 +760,22 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.xl,
},
emptyIconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.lg,
},
emptyTitle: {
...typography.title3,
color: colors.text,
marginBottom: spacing.sm,
},
emptyDescription: {
...typography.body,
color: colors.textSecondary,
+34 -6
View File
@@ -47,7 +47,7 @@ const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
type GroupByOption = "pack" | "date" | "answer";
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 router = useRouter();
const params = useLocalSearchParams();
@@ -123,7 +123,7 @@ export default function MyAnswersScreen() {
}
}, [isLoading, isLoadingMore, hasMore]);
const sections = groupResponses(responses, groupBy);
const sections = groupResponses(responses, groupBy, dateSortOrder);
const handleEditPress = (response: ResponseWithQuestion) => {
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 }) => {
const isActive = groupBy === option;
const isDateButton = option === "date";
const showChevron = isDateButton && isActive;
const handlePress = () => {
if (isDateButton && isActive) {
// Toggle sort order when date is already selected
toggleDateSortOrder();
} else {
setGroupBy(option);
}
};
return (
<TouchableOpacity
style={[styles.groupByButton, groupBy === option && styles.groupByButtonActive]}
onPress={() => setGroupBy(option)}
style={[styles.groupByButton, isActive && styles.groupByButtonActive]}
onPress={handlePress}
>
<Text style={[styles.groupByText, groupBy === option && styles.groupByTextActive]}>
<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) {
return (
@@ -441,6 +464,8 @@ const styles = StyleSheet.create({
marginTop: spacing.md,
},
groupByButton: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
@@ -461,6 +486,9 @@ const styles = StyleSheet.create({
color: ACCENT,
fontWeight: "600",
},
sortChevron: {
marginLeft: spacing.xs,
},
// Section header
sectionHeader: {
paddingVertical: spacing.sm,
+332 -13
View File
@@ -24,9 +24,18 @@ import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // P
import { SwipeTutorial } from "../../src/components/tutorials";
import { QuestionFeedbackModal } from "../../src/components/feedback";
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { Paywall } from "../../src/components/paywall";
import { Events } from "../../src/lib/analytics";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
interface DailyLimitInfo {
responses_today: number;
limit_value: number;
remaining: number;
reset_at: string;
is_blocked: boolean;
}
export default function SwipeScreen() {
const params = useLocalSearchParams();
// Normalize packId - useLocalSearchParams can return string | string[]
@@ -38,6 +47,8 @@ export default function SwipeScreen() {
const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null);
const [isBlocked, setIsBlocked] = useState(false);
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
const [dailyLimitInfo, setDailyLimitInfo] = useState<DailyLimitInfo | null>(null);
const [showPaywall, setShowPaywall] = useState(false);
const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore();
const { partner, couple } = useAuthStore();
const hasTrackedExhausted = useRef(false);
@@ -49,6 +60,40 @@ export default function SwipeScreen() {
// Progress bar shimmer animation
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(() => {
// Progress bar shimmer sweep - 2.5 second cycle
shimmerPosition.value = withRepeat(
@@ -139,16 +184,48 @@ export default function SwipeScreen() {
}
};
// Calculate effective total questions (accounting for gap limit)
const effectiveTotal = (() => {
if (!gapInfo || gapInfo.threshold === 0) {
// No gap limit active, show all questions
return questions.length;
// Check if user has reached daily response limit
const checkDailyLimit = async (): Promise<boolean> => {
try {
const { data, error } = await supabase.rpc('get_daily_response_limit_status');
if (error) {
console.error('Error checking daily limit:', error);
setDailyLimitInfo(null);
return false;
}
// remaining = how many more questions until blocked
if (data && data.length > 0) {
const result = data[0];
setDailyLimitInfo(result);
return result.is_blocked;
}
setDailyLimitInfo(null);
return false;
} catch (error) {
console.error('Failed to check daily limit:', error);
setDailyLimitInfo(null);
return false;
}
};
// Calculate effective total questions (accounting for gap limit AND daily limit)
const effectiveTotal = (() => {
let total = questions.length;
// Apply gap limit if active
if (gapInfo && gapInfo.threshold > 0) {
const remaining = gapInfo.threshold - gapInfo.unanswered;
// effective total = current position + remaining (capped by actual questions)
return Math.min(questions.length, currentIndex + Math.max(0, remaining));
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
@@ -212,14 +289,14 @@ export default function SwipeScreen() {
// Filter out recently skipped questions
const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id));
// Always check answer gap when user has partner and not viewing specific pack
// This gives us the gap info for accurate counter display
// Always check answer gap and daily limit when user has partner and not viewing specific pack
if (partner && !packId) {
await checkAnswerGap();
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
} else {
// Reset blocked state if no partner or viewing specific pack
setIsBlocked(false);
setGapInfo(null);
setDailyLimitInfo(null);
}
// Server provides ordering (daily seeded random with priority)
@@ -264,8 +341,19 @@ export default function SwipeScreen() {
console.error("Submit response error:", error);
} else {
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) {
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
if (isBlocked && gapInfo) {
const WAITING_ACCENT = colors.premium.rose;
@@ -656,6 +855,17 @@ export default function SwipeScreen() {
questionId={feedbackQuestion?.id || ''}
questionText={feedbackQuestion?.text || ''}
/>
{/* Paywall Modal */}
<Paywall
visible={showPaywall}
onClose={() => setShowPaywall(false)}
onSuccess={() => {
setShowPaywall(false);
// Refresh to clear limit status
fetchQuestions();
}}
/>
</GradientBackground>
);
}
@@ -889,4 +1099,113 @@ const styles = StyleSheet.create({
textAlign: 'center',
marginBottom: spacing.xs,
},
// Daily limit screen styles
dailyLimitOrbTop: {
top: 80,
right: -60,
},
dailyLimitOrbBottom: {
bottom: 200,
left: -80,
},
dailyLimitIconWrapper: {
position: 'relative',
marginBottom: spacing.xl,
},
dailyLimitIconGlow: {
position: 'absolute',
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: 'rgba(212, 175, 55, 0.15)',
top: -24,
left: -24,
},
dailyLimitIconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(212, 175, 55, 0.1)',
borderWidth: 1.5,
borderColor: 'rgba(212, 175, 55, 0.3)',
justifyContent: 'center',
alignItems: 'center',
},
dailyLimitLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: colors.premium.gold,
textAlign: 'center',
marginBottom: spacing.xs,
},
dailyLimitTitle: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
countdownContainer: {
alignItems: 'center',
marginBottom: spacing.xl,
},
countdownLabel: {
...typography.caption2,
letterSpacing: 1.5,
color: colors.textTertiary,
marginBottom: spacing.sm,
},
countdownBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(212, 175, 55, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.25)',
gap: spacing.sm,
},
countdownText: {
...typography.title2,
fontWeight: '700',
color: colors.premium.gold,
fontVariant: ['tabular-nums'],
letterSpacing: 2,
},
dailyLimitDescription: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.lg,
lineHeight: 24,
paddingHorizontal: spacing.md,
},
premiumUpsellText: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
marginBottom: spacing.md,
},
premiumButton: {
position: 'relative',
borderRadius: radius.lg,
overflow: 'hidden',
...shadows.md,
},
premiumButtonGradient: {
...StyleSheet.absoluteFillObject,
},
premiumButtonContent: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
gap: spacing.sm,
},
premiumButtonText: {
...typography.body,
fontWeight: '600',
color: '#000',
},
});
+3 -3
View File
@@ -47,7 +47,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>20</string>
<string>25</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
@@ -60,7 +60,7 @@
<true/>
</dict>
<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>
<string>Sauci uses Face ID to unlock the app and keep your conversations private.</string>
<key>NSMicrophoneUsageDescription</key>
@@ -68,7 +68,7 @@
<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>
<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>
<array>
<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,8 +6,14 @@ import { useMediaPicker } from '@/hooks/useMediaPicker';
describe('useMediaPicker', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('pickMedia', () => {
it('returns null when user cancels library picker', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: true,
@@ -79,6 +85,42 @@ describe('useMediaPicker', () => {
});
});
it('uses custom config options', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: true,
});
const { result } = renderHook(() => useMediaPicker({
imageQuality: 0.5,
libraryVideoMaxDuration: 120,
}));
await act(async () => {
await result.current.pickMedia();
});
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({
mediaTypes: ImagePicker.MediaTypeOptions.All,
quality: 0.5,
videoMaxDuration: 120,
});
});
});
describe('requestCameraPermission', () => {
it('returns true when permission granted', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
const { result } = renderHook(() => useMediaPicker());
let ok: any;
await act(async () => {
ok = await result.current.requestCameraPermission();
});
expect(ok).toBe(true);
});
it('alerts and returns false when camera permission denied', async () => {
const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
@@ -91,6 +133,228 @@ describe('useMediaPicker', () => {
});
expect(ok).toBe(false);
expect(alertSpy).toHaveBeenCalled();
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),
order: jest.fn(() => query),
range: jest.fn(() => query),
update: jest.fn(() => query),
insert: jest.fn(() => query),
delete: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
@@ -19,10 +22,24 @@ function createThenableQuery(result: any) {
describe('matchStore', () => {
beforeEach(() => {
jest.clearAllMocks();
useMatchStore.setState({ matches: [], newMatchesCount: 0, isLoading: false, error: null } as any);
useMatchStore.setState({
matches: [],
newMatchesCount: 0,
totalCount: null,
isLoading: false,
isLoadingMore: false,
page: 0,
hasMore: true,
error: null,
archivedMatches: [],
archivedMatchIds: new Set<string>(),
showArchived: false,
isLoadingArchived: false,
});
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
});
describe('fetchMatches', () => {
it('sorts matches with unread messages first', async () => {
const matches = [
{ id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: true },
@@ -31,19 +48,438 @@ describe('matchStore', () => {
const unreadMessages = [{ match_id: 'a' }, { match_id: 'a' }];
const archivesQuery = createThenableQuery({ data: [] });
const countQuery = createThenableQuery({ count: 2 });
const matchesQuery = createThenableQuery({ data: matches });
const responsesQuery = createThenableQuery({ data: [] });
const unreadQuery = createThenableQuery({ data: unreadMessages, error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(archivesQuery) // archived matches
.mockReturnValueOnce(countQuery) // count
.mockReturnValueOnce(matchesQuery) // matches
.mockReturnValueOnce(responsesQuery) // responses
.mockReturnValueOnce(unreadQuery); // messages
await useMatchStore.getState().fetchMatches();
await useMatchStore.getState().fetchMatches(true);
const state = useMatchStore.getState();
expect(state.matches[0].id).toBe('a');
expect(state.newMatchesCount).toBe(1);
});
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', () => {
const service = {
jest.mock('../../lib/revenuecat', () => ({
__esModule: true,
default: {
isAvailable: jest.fn(() => true),
isInitialized: jest.fn(() => true),
initialize: jest.fn(async () => {}),
login: jest.fn(async () => null),
logout: jest.fn(async () => {}),
addCustomerInfoUpdateListener: jest.fn(() => null),
addCustomerInfoUpdateListener: jest.fn(() => () => {}),
getOfferingsDebug: jest.fn(async () => ({
current: { identifier: 'default', availablePackages: [{ identifier: 'monthly', packageType: 'MONTHLY', product: { price: 1, priceString: '$1', currencyCode: 'USD' } }] },
availableOfferings: ['default'],
@@ -15,24 +16,33 @@ jest.mock('../../lib/revenuecat', () => {
restorePurchases: jest.fn(async () => ({ entitlements: { active: {} } })),
getCustomerInfo: jest.fn(async () => ({ entitlements: { active: {} } })),
parseCustomerInfo: jest.fn(() => ({ isProUser: false, activeSubscription: null, expirationDate: null, willRenew: false })),
};
return {
__esModule: true,
default: service,
revenueCatService: service,
},
get revenueCatService() {
return this.default;
},
SubscriptionState: {},
PurchasesPackage: {},
PurchasesOffering: {},
};
});
}));
import { useSubscriptionStore } from '@/store/subscriptionStore';
import { useAuthStore } from '@/store/authStore';
import { supabase } from '@/lib/supabase';
import revenueCatService from '../../lib/revenuecat';
describe('subscriptionStore', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset mock implementations to defaults
(revenueCatService.isAvailable as jest.Mock).mockReturnValue(true);
(revenueCatService.isInitialized as jest.Mock).mockReturnValue(true);
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
isProUser: false,
activeSubscription: null,
expirationDate: null,
willRenew: false,
});
useSubscriptionStore.setState({
subscription: { isProUser: false, activeSubscription: null, expirationDate: null, willRenew: false },
offerings: null,
@@ -40,10 +50,42 @@ describe('subscriptionStore', () => {
isPurchasing: false,
error: null,
} as any);
useAuthStore.setState({ user: { id: 'me', is_premium: false } } as any);
useAuthStore.setState({
user: { id: 'me', is_premium: false },
fetchUser: jest.fn(),
} as any);
});
it('fetchOfferings stores offering on success', async () => {
describe('initializeRevenueCat', () => {
it('skips initialization if RevenueCat not available', async () => {
(revenueCatService.isAvailable as jest.Mock).mockReturnValue(false);
await useSubscriptionStore.getState().initializeRevenueCat('user1');
expect(revenueCatService.initialize).not.toHaveBeenCalled();
});
it('initializes and logs in when available', async () => {
await useSubscriptionStore.getState().initializeRevenueCat('user1');
expect(revenueCatService.initialize).toHaveBeenCalledWith('user1');
expect(revenueCatService.login).toHaveBeenCalledWith('user1');
expect(revenueCatService.addCustomerInfoUpdateListener).toHaveBeenCalled();
});
it('handles initialization errors gracefully', async () => {
(revenueCatService.initialize as jest.Mock).mockRejectedValueOnce(new Error('Init failed'));
// Should not throw
await useSubscriptionStore.getState().initializeRevenueCat('user1');
// Error is logged but not set in state
expect(useSubscriptionStore.getState().error).toBeNull();
});
});
describe('fetchOfferings', () => {
it('stores offering on success', async () => {
await useSubscriptionStore.getState().fetchOfferings();
const state = useSubscriptionStore.getState();
@@ -52,12 +94,192 @@ describe('subscriptionStore', () => {
expect(state.error).toBeNull();
});
it('clearSubscription resets state', () => {
useSubscriptionStore.setState({ offerings: { identifier: 'x', availablePackages: [] } } as any);
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>
</View>
{/* Partner status indicator */}
{response.partner_answered && (
<View style={styles.partnerBadge}>
<Ionicons name="person" size={10} color={colors.textTertiary} />
</View>
)}
</View>
</View>
@@ -215,14 +209,6 @@ const styles = StyleSheet.create({
...typography.caption2,
color: colors.textTertiary,
},
partnerBadge: {
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: "rgba(255, 255, 255, 0.08)",
justifyContent: "center",
alignItems: "center",
},
rightSection: {
flexDirection: "row",
alignItems: "center",
+104 -170
View File
@@ -1,21 +1,18 @@
import { useState, useEffect, useCallback } from "react";
import { View, Text, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Modal, Dimensions, Alert } from "react-native";
import { Image } from "expo-image";
import { Video, ResizeMode } from "expo-av";
import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown, useAnimatedStyle } from "react-native-reanimated";
import Animated, { FadeInDown } from "react-native-reanimated";
import { InputBar, ChatHeader, ChatMessages, ReportMessageSheet } from "./components";
import { InputBar, ChatHeader, ChatMessages, ReportMessageSheet, ChatSettingsSheet, FullScreenImageModal, FullScreenVideoModal } from "./components";
import { useMediaUpload } from "./hooks/useMediaUpload";
import { useMessageActions } from "./hooks/useMessageActions";
import type { ReportReason } from "./types";
import type { ReportReason, Match } from "./types";
import { supabase } from "../../lib/supabase";
import { useAuthStore, useMessageStore } from "../../store";
import { useAuthStore, useMessageStore, useMatchStore } from "../../store";
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useMediaSaver } from "../../hooks";
import { Database } from "../../types/supabase";
import { GradientBackground } from "../../components/ui";
@@ -25,7 +22,6 @@ import { Events } from "../../lib/analytics";
type Message = Database["public"]["Tables"]["messages"]["Row"];
// Premium color palette for Chat
const ACCENT = colors.premium.gold;
const ROSE_RGBA = 'rgba(232, 164, 174, ';
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
@@ -36,8 +32,10 @@ export const ChatScreen: React.FC = () => {
const insets = useSafeAreaInsets();
const { user, partner } = useAuthStore();
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
const [match, setMatch] = useState<any>(null);
const { archiveMatch, unarchiveMatch, isMatchArchived } = useMatchStore();
const [match, setMatch] = useState<Match | null>(null);
const [inputText, setInputText] = useState("");
const [showSettingsSheet, setShowSettingsSheet] = useState(false);
const { uploading, uploadStatus, uploadMedia } = useMediaUpload(matchId, user?.id);
const { saveMedia, saving: mediaSaving } = useMediaSaver();
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
@@ -81,6 +79,62 @@ export const ChatScreen: React.FC = () => {
onReport: (message) => setReportingMessage(message),
});
// Memoized navigation callbacks
const handleBack = useCallback(() => {
router.push('/(app)/matches');
}, [router]);
const handleOpenSettings = useCallback(() => {
setShowSettingsSheet(true);
}, []);
const handleCloseSettings = useCallback(() => {
setShowSettingsSheet(false);
}, []);
// Memoized modal callbacks
const handleCloseImage = useCallback(() => {
setFullScreenImage(null);
}, []);
const handleSaveImage = useCallback(() => {
if (fullScreenImage) {
saveMedia(fullScreenImage, 'image');
}
}, [fullScreenImage, saveMedia]);
const handleImageLoadStart = useCallback(() => {
setFullScreenImageLoading(true);
}, []);
const handleImageLoadEnd = useCallback(() => {
setFullScreenImageLoading(false);
}, []);
const handleCloseVideo = useCallback(() => {
setFullScreenVideo(null);
}, []);
const handleSaveVideo = useCallback(() => {
if (fullScreenVideo) {
saveMedia(fullScreenVideo, 'video');
}
}, [fullScreenVideo, saveMedia]);
const handleCloseReport = useCallback(() => {
setReportingMessage(null);
}, []);
// Memoized archive/unarchive callbacks
const handleArchive = useCallback(async () => {
await archiveMatch(matchId);
router.push('/(app)/matches');
}, [archiveMatch, matchId, router]);
const handleUnarchive = useCallback(async () => {
await unarchiveMatch(matchId);
}, [unarchiveMatch, matchId]);
// Handle long press on message to show options
const handleMessageLongPress = useCallback((message: Message, isMe: boolean) => {
// Don't show options for already deleted messages
@@ -89,7 +143,7 @@ export const ChatScreen: React.FC = () => {
}, [showDeleteOptions]);
// Handle report submission
const handleSubmitReport = async (reason: ReportReason) => {
const handleSubmitReport = useCallback(async (reason: ReportReason) => {
if (!reportingMessage || !user?.id) return;
try {
@@ -127,7 +181,7 @@ export const ChatScreen: React.FC = () => {
Alert.alert('Error', 'Failed to submit report');
setReportingMessage(null);
}
};
}, [reportingMessage, user?.id, deleteForSelf, setMessages]);
// Track active chat to prevent notifications for current chat
useFocusEffect(
@@ -149,6 +203,7 @@ export const ChatScreen: React.FC = () => {
if (!matchId) return;
const fetchMatch = async () => {
setMatch(null); // Clear previous match data immediately
const { data } = await supabase
.from("matches")
.select("*, question:questions(*)")
@@ -168,15 +223,15 @@ export const ChatScreen: React.FC = () => {
fetchMatch();
}, [matchId]);
const handleTyping = (text: string) => {
const handleTyping = useCallback((text: string) => {
setInputText(text);
if (text.length > 0) {
sendTypingEvent();
}
};
}, [sendTypingEvent]);
const handleSend = async () => {
const handleSend = useCallback(async () => {
if (!inputText.trim()) return;
if (!user) return;
@@ -203,30 +258,30 @@ export const ChatScreen: React.FC = () => {
Alert.alert("Delivery Failed", "Couldn't send message. Please check your connection.");
setInputText(content);
}
};
}, [inputText, user, matchId]);
const handlePickMedia = async () => {
const handlePickMedia = useCallback(async () => {
const result = await pickMedia();
if (result) {
uploadMedia(result.uri, result.mediaType);
}
};
}, [pickMedia, uploadMedia]);
const handleTakePhoto = async () => {
const handleTakePhoto = useCallback(async () => {
const result = await takePhoto();
if (result) {
uploadMedia(result.uri, 'image');
}
};
}, [takePhoto, uploadMedia]);
const handleRecordVideo = async () => {
const handleRecordVideo = useCallback(async () => {
const result = await recordVideo();
if (result) {
uploadMedia(result.uri, 'video');
}
};
}, [recordVideo, uploadMedia]);
const revealMessage = async (messageId: string) => {
const revealMessage = useCallback(async (messageId: string) => {
const message = messages.find(m => m.id === messageId);
const now = new Date();
// Set expiration 30 days from now for videos only
@@ -249,7 +304,7 @@ export const ChatScreen: React.FC = () => {
media_expires_at: expiresAt
})
.eq("id", messageId);
};
}, [messages, setMessages]);
return (
<GradientBackground>
@@ -279,10 +334,9 @@ export const ChatScreen: React.FC = () => {
<ChatHeader
partner={partner}
user={user}
match={match}
insets={insets}
onBack={() => router.push('/(app)/matches')}
onBack={handleBack}
onSettingsPress={handleOpenSettings}
/>
@@ -326,124 +380,41 @@ export const ChatScreen: React.FC = () => {
</KeyboardAvoidingView>
{/* Full Screen Image Modal */}
<Modal
<FullScreenImageModal
uri={fullScreenImage}
visible={!!fullScreenImage}
transparent
animationType="fade"
onRequestClose={() => setFullScreenImage(null)}
>
<View style={styles.fullScreenOverlay}>
<TouchableOpacity
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)}
loading={fullScreenImageLoading}
saving={mediaSaving}
onClose={handleCloseImage}
onSave={handleSaveImage}
onLoadStart={handleImageLoadStart}
onLoadEnd={handleImageLoadEnd}
/>
)}
</View>
</Modal>
{/* Full Screen Video Modal */}
<Modal
<FullScreenVideoModal
uri={fullScreenVideo}
visible={!!fullScreenVideo}
transparent
animationType="fade"
onRequestClose={() => setFullScreenVideo(null)}
>
<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}
saving={mediaSaving}
onClose={handleCloseVideo}
onSave={handleSaveVideo}
/>
)}
</View>
</Modal>
{/* Report Message Sheet */}
<ReportMessageSheet
visible={reportingMessage !== null}
onClose={() => setReportingMessage(null)}
onClose={handleCloseReport}
onSubmit={handleSubmitReport}
/>
{/* Chat Settings Sheet */}
<ChatSettingsSheet
visible={showSettingsSheet}
onClose={handleCloseSettings}
isArchived={isMatchArchived(matchId)}
onArchive={handleArchive}
onUnarchive={handleUnarchive}
/>
</GradientBackground>
);
};
@@ -472,43 +443,6 @@ const styles = StyleSheet.create({
height: '100%',
borderRadius: 140,
},
// Full Screen Modal
fullScreenOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.95)",
justifyContent: "center",
alignItems: "center",
},
fullScreenCloseButton: {
position: "absolute",
top: 50,
right: 20,
zIndex: 10,
},
fullScreenSaveButton: {
position: "absolute",
top: 50,
left: 20,
zIndex: 10,
},
fullScreenCloseGradient: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
fullScreenImage: {
width: Dimensions.get("window").width,
height: Dimensions.get("window").height * 0.8,
},
fullScreenVideo: {
width: Dimensions.get("window").width,
height: Dimensions.get("window").height * 0.8,
},
fullScreenSpinner: {
position: "absolute",
},
inputWrapper: {
overflow: 'hidden',
},
@@ -15,16 +15,16 @@ const ACCENT_RGBA = 'rgba(212, 175, 55, ';
interface ChatHeaderProps {
partner: Profile | null;
user: { id: string } | null;
match: any; // Kept for compatibility but unused
insets: EdgeInsets;
onBack: () => void;
onSettingsPress?: () => void;
}
export const ChatHeader: React.FC<ChatHeaderProps> = ({
const ChatHeaderComponent: React.FC<ChatHeaderProps> = ({
partner,
insets,
onBack,
onSettingsPress,
}) => {
return (
<Animated.View
@@ -50,6 +50,8 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
onPress={onBack}
style={styles.backButton}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Go back to matches"
>
<View style={styles.backButtonInner}>
<Ionicons name="chevron-back" size={20} color={ACCENT} />
@@ -86,7 +88,21 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
</View>
</View>
{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 */}
<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({
header: {
flexDirection: 'row',
@@ -179,6 +198,12 @@ const styles = StyleSheet.create({
headerSpacer: {
width: 40,
},
settingsButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
headerBorderBottom: {
position: 'absolute',
bottom: 0,
@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useRef, useCallback, useMemo } from 'react';
import { View, Text, FlatList, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeIn } from 'react-native-reanimated';
@@ -12,14 +12,14 @@ import { MatchCard } from './MatchCard';
import { DecorativeSeparator } from '../../../components/ui';
import { colors, spacing, typography } from '../../../theme';
import { Database } from '../../../types/supabase';
import { UploadStatus } from '../types';
import { UploadStatus, Match } from '../types';
type Message = Database['public']['Tables']['messages']['Row'];
interface ChatMessagesProps {
messages: Message[];
userId: string | undefined;
match: any;
match: Match | null;
uploadStatus: UploadStatus | null;
partnerTyping: boolean;
onImagePress: (uri: string) => void;
@@ -43,7 +43,7 @@ function isSameDay(date1: Date, date2: Date): boolean {
);
}
export const ChatMessages: React.FC<ChatMessagesProps> = ({
const ChatMessagesComponent: React.FC<ChatMessagesProps> = ({
messages,
userId,
match,
@@ -56,7 +56,14 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
}) => {
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 messageDate = new Date(item.created_at!);
@@ -70,7 +77,7 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
);
return (
<>
<View>
<MessageBubble
isMe={isMe}
index={index}
@@ -86,58 +93,68 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
/>
</MessageBubble>
{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 (
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
keyExtractor={keyExtractor}
inverted
style={styles.messageList}
contentContainerStyle={styles.listContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<>
{/* Show uploading status when media is being uploaded */}
{uploadStatus && <UploadProgress uploadStatus={uploadStatus} />}
{/* Show typing indicator when partner is typing */}
{partnerTyping && <TypingIndicator />}
</>
}
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>
}
// Performance optimizations
removeClippedSubviews={true}
maxToRenderPerBatch={15}
windowSize={10}
initialNumToRender={20}
ListHeaderComponent={listHeaderComponent}
ListFooterComponent={listFooterComponent}
ListEmptyComponent={listEmptyComponent}
/>
);
};
// Wrap with React.memo for performance
export const ChatMessages = React.memo(ChatMessagesComponent);
const styles = StyleSheet.create({
messageList: {
flex: 1,
@@ -146,6 +163,9 @@ const styles = StyleSheet.create({
flexGrow: 1,
padding: spacing.md,
},
footerContainer: {
paddingBottom: spacing.md,
},
emptyChat: {
alignItems: 'center',
justifyContent: 'center',
@@ -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.
* Supports play/pause, fullscreen, caching, and loading states.
*/
export function ChatVideoPlayer({
const ChatVideoPlayerComponent: React.FC<ChatVideoPlayerProps> = ({
signedUrl,
storagePath,
urlError,
onFullScreen,
}: ChatVideoPlayerProps) {
}) => {
const videoRef = useRef<Video>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [hasEnded, setHasEnded] = useState(false);
@@ -43,11 +43,6 @@ export function ChatVideoPlayer({
const isLocalFile = signedUrl?.startsWith('file://');
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
const handlePlaybackStatusUpdate = useCallback(async (status: AVPlaybackStatus) => {
if (!status.isLoaded) {
@@ -71,7 +66,7 @@ export function ChatVideoPlayer({
cacheVideoFile();
}
}
}, [signedUrl, storagePath, cachedUri, isLocalFile]);
}, [signedUrl, cachedUri, isLocalFile, cacheVideoFile]);
// Toggle play/pause with native-like behavior
const handleTapToPlay = useCallback(async () => {
@@ -93,7 +88,7 @@ export function ChatVideoPlayer({
cacheVideoFile();
}
}
}, [isPlaying, hasEnded, signedUrl, storagePath, cachedUri]);
}, [isPlaying, hasEnded, signedUrl, cachedUri, cacheVideoFile]);
// Handle full screen
const handleFullScreen = useCallback(() => {
@@ -180,7 +175,10 @@ export function ChatVideoPlayer({
)}
</TouchableOpacity>
);
}
};
// Wrap with React.memo for performance
export const ChatVideoPlayer = React.memo(ChatVideoPlayerComponent);
const styles = StyleSheet.create({
container: {
@@ -245,5 +243,3 @@ const styles = StyleSheet.create({
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.
* Includes text input, send button, and media action buttons.
*/
export function InputBar({
const InputBarComponent: React.FC<InputBarProps> = ({
inputText,
uploading,
onChangeText,
@@ -35,7 +35,7 @@ export function InputBar({
onPickMedia,
onTakePhoto,
onRecordVideo,
}: InputBarProps) {
}) => {
const [menuExpanded, setMenuExpanded] = useState(false);
const menuWidth = useSharedValue(0);
const buttonRotation = useSharedValue(0);
@@ -176,7 +176,10 @@ export function InputBar({
</TouchableOpacity>
</View>
);
}
};
// Wrap with React.memo for performance
export const InputBar = React.memo(InputBarComponent);
const styles = StyleSheet.create({
container: {
@@ -261,5 +264,3 @@ const styles = StyleSheet.create({
height: 20,
},
});
export default InputBar;
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
@@ -6,24 +6,38 @@ import Animated, { FadeInDown } from 'react-native-reanimated';
import { DecorativeSeparator } from '../../../components/ui';
import { colors, gradients, spacing, radius, typography } from '../../../theme';
import { Match } from '../types';
// Premium color palette
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
interface MatchCardProps {
match: any;
match: Match | null;
user: { id: string } | null;
}
export const MatchCard: React.FC<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;
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 (
<Animated.View
entering={FadeInDown.delay(100).duration(400).springify()}
entering={enteringAnimation}
style={styles.matchCardContainer}
>
<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
const sortedResponses = [...match.responses].sort(
(a: any, b: any) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
const isTwoPart = !!match.question.partner_text;
const isTwoPart = !!match.question?.partner_text;
return sortedResponses.map((response: any, index: number) => {
return sortedResponses.map((response, index) => {
const isUser = response.user_id === user?.id;
const isYes = response.answer === 'yes';
const name = isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner');
// First responder sees main text, second sees partner_text
const questionText = isTwoPart && index === 1
? match.question.partner_text
: match.question.text;
? match.question?.partner_text
: match.question?.text;
return (
<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({
matchCardContainer: {
marginHorizontal: spacing.md,
@@ -10,6 +10,9 @@ import { colors, gradients, spacing, radius } from '../../../theme';
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
// Only animate the first few messages to avoid performance issues on large lists
const MAX_ANIMATED_INDEX = 5;
export interface MessageBubbleProps {
isMe: boolean;
index: number;
@@ -18,10 +21,18 @@ export interface MessageBubbleProps {
onLongPress?: () => void;
}
export function MessageBubble({ isMe, index, children, onLongPress }: MessageBubbleProps) {
const MessageBubbleComponent = ({ isMe, index, children, onLongPress }: MessageBubbleProps) => {
// Only animate recent messages (low index in inverted list) to avoid performance issues
const shouldAnimate = index < MAX_ANIMATED_INDEX;
const enteringAnimation = shouldAnimate ? FadeInUp.duration(200) : undefined;
const accessibilityLabel = isMe
? 'Your message. Long press for options'
: "Partner's message. Long press for options";
return (
<Animated.View
entering={FadeInUp.delay(index * 30).duration(200)}
entering={enteringAnimation}
style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}
>
{isMe ? (
@@ -30,6 +41,9 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub
activeOpacity={0.9}
delayLongPress={300}
style={styles.myBubbleContainer}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Long press to delete or report"
>
<LinearGradient
colors={gradients.primary as [string, string]}
@@ -53,6 +67,9 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub
activeOpacity={0.9}
delayLongPress={300}
style={styles.theirBubbleContainer}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Long press to report"
>
<View style={[styles.bubble, styles.theirBubble]}>
{/* Subtle gradient background */}
@@ -75,7 +92,10 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub
)}
</Animated.View>
);
}
};
// Wrap with React.memo for performance
export const MessageBubble = React.memo(MessageBubbleComponent);
const styles = StyleSheet.create({
messageRow: {
@@ -116,5 +136,3 @@ const styles = StyleSheet.create({
height: 30,
},
});
export default MessageBubble;
@@ -22,18 +22,16 @@ export interface MessageContentProps {
revealMessage: (id: string) => void;
onImagePress: (uri: string) => void;
onVideoFullScreen: (uri: string) => void;
onLongPress?: () => void;
}
export function MessageContent({
const MessageContentComponent: React.FC<MessageContentProps> = ({
item,
isMe,
currentUserId,
revealMessage,
onImagePress,
onVideoFullScreen,
onLongPress,
}: MessageContentProps) {
}) => {
// Check if message was deleted for everyone
if (item.deleted_at) {
return (
@@ -50,7 +48,7 @@ export function MessageContent({
);
}
const isVideo = (item as any).media_type === 'video';
const isVideo = item.media_type === 'video';
const isViewed = !!item.media_viewed_at;
const isRecipientHidden = !isMe && !isViewed;
@@ -87,7 +85,7 @@ export function MessageContent({
}, [item.media_path, isRecipientHidden]);
// Handle expired videos
const isExpired = !!(item as any).media_expired;
const isExpired = !!item.media_expired;
if (isExpired && isVideo) {
return (
@@ -240,7 +238,10 @@ export function MessageContent({
<MessageMeta item={item} isMe={isMe} />
</>
);
}
};
// Wrap with React.memo for performance
export const MessageContent = React.memo(MessageContentComponent);
const styles = StyleSheet.create({
deletedContainer: {
@@ -347,5 +348,3 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
},
});
export default MessageContent;
@@ -12,7 +12,7 @@ export interface MessageMetaProps {
/**
* Message metadata component showing timestamp and read receipts.
*/
export function MessageMeta({ item, isMe }: MessageMetaProps) {
const MessageMetaComponent: React.FC<MessageMetaProps> = ({ item, isMe }) => {
const status = getReceiptStatus(item.delivered_at, item.read_at);
return (
@@ -27,7 +27,10 @@ export function MessageMeta({ item, isMe }: MessageMetaProps) {
)}
</View>
);
}
};
// Wrap with React.memo for performance
export const MessageMeta = React.memo(MessageMetaComponent);
const styles = StyleSheet.create({
container: {
@@ -43,5 +46,3 @@ const styles = StyleSheet.create({
marginLeft: spacing.xs,
},
});
export default MessageMeta;
@@ -15,7 +15,7 @@ interface TypingDotProps {
delay: number;
}
function TypingDot({ delay }: TypingDotProps) {
const TypingDot = React.memo(function TypingDot({ delay }: TypingDotProps) {
const opacity = useSharedValue(0.4);
useEffect(() => {
@@ -43,13 +43,13 @@ function TypingDot({ delay }: TypingDotProps) {
/>
</Animated.View>
);
}
});
/**
* Typing indicator component showing three animated dots
* when partner is typing.
*/
export function TypingIndicator() {
const TypingIndicatorComponent: React.FC = () => {
return (
<Animated.View
entering={FadeIn.duration(200)}
@@ -71,7 +71,10 @@ export function TypingIndicator() {
</View>
</Animated.View>
);
}
};
// Wrap with React.memo for performance
export const TypingIndicator = React.memo(TypingIndicatorComponent);
const styles = StyleSheet.create({
container: {
@@ -104,5 +107,3 @@ const styles = StyleSheet.create({
borderRadius: 4,
},
});
export default TypingIndicator;
@@ -16,3 +16,6 @@ export { ChatHeader } from './ChatHeader';
export { ChatMessages } from './ChatMessages';
export { MatchCard } from './MatchCard';
export { ReportMessageSheet } from './ReportMessageSheet';
export { ChatSettingsSheet } from './ChatSettingsSheet';
export { FullScreenImageModal } from './FullScreenImageModal';
export { FullScreenVideoModal } from './FullScreenVideoModal';
+1
View File
@@ -25,6 +25,7 @@ export interface Match {
responses?: Array<{
user_id: string;
answer: string;
created_at: string;
profiles?: {
name?: string;
};
@@ -104,6 +104,7 @@ export const useMessageSubscription = (
const fetchMessagesAndMarkRead = async () => {
setLoading(true);
setAllMessages([]); // Clear previous chat's messages immediately
const { data } = await supabase
.from('messages')
+24 -5
View File
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { RealtimeChannel } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase';
export interface UseTypingIndicatorConfig {
@@ -10,6 +11,8 @@ export interface UseTypingIndicatorConfig {
typingTimeout?: number;
/** Whether the screen is currently focused */
isFocused?: boolean;
/** Throttle interval in ms for sending typing events. Default: 2000 */
throttleInterval?: number;
}
export interface UseTypingIndicatorReturn {
@@ -22,6 +25,7 @@ export interface UseTypingIndicatorReturn {
}
const DEFAULT_TYPING_TIMEOUT = 3000;
const DEFAULT_THROTTLE_INTERVAL = 2000;
/**
* Hook for managing typing indicator state and broadcast events.
@@ -33,11 +37,19 @@ const DEFAULT_TYPING_TIMEOUT = 3000;
export const useTypingIndicator = (
config: UseTypingIndicatorConfig
): UseTypingIndicatorReturn => {
const { channelName, userId, typingTimeout = DEFAULT_TYPING_TIMEOUT, isFocused = true } = config;
const {
channelName,
userId,
typingTimeout = DEFAULT_TYPING_TIMEOUT,
isFocused = true,
throttleInterval = DEFAULT_THROTTLE_INTERVAL,
} = config;
const [partnerTyping, setPartnerTyping] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
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
useEffect(() => {
@@ -65,6 +77,7 @@ export const useTypingIndicator = (
if (!channelName || !userId) return;
const channel = supabase.channel(channelName);
channelRef.current = channel;
channel.on('broadcast', { event: 'typing' }, (payload) => {
// Only process events if the screen is focused
@@ -88,6 +101,7 @@ export const useTypingIndicator = (
channel.subscribe();
return () => {
channelRef.current = null;
supabase.removeChannel(channel);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
@@ -96,17 +110,22 @@ export const useTypingIndicator = (
}, [channelName, userId, typingTimeout]);
/**
* Send a typing event to the partner
* Send a typing event to the partner (throttled to reduce network traffic)
*/
const sendTypingEvent = useCallback(() => {
if (!channelName || !userId) return;
if (!channelRef.current || !userId) return;
supabase.channel(channelName).send({
// Throttle typing events to reduce network traffic
const now = Date.now();
if (now - lastTypingEventRef.current < throttleInterval) return;
lastTypingEventRef.current = now;
channelRef.current.send({
type: 'broadcast',
event: 'typing',
payload: { userId },
});
}, [channelName, userId]);
}, [userId, throttleInterval]);
/**
* Manually clear the typing indicator (e.g., when a message is received)
+14 -49
View File
@@ -1,7 +1,15 @@
import type { Ionicons } from "@expo/vector-icons";
import { Ionicons } from "@expo/vector-icons";
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)
const EMOJI_TO_IONICON: Record<string, IoniconsName> = {
// Career & Professional
@@ -49,63 +57,20 @@ export const PACK_ICON_MAP = EMOJI_TO_IONICON;
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.
* 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"
*/
export function getPackIconName(icon: string | null | undefined): IoniconsName {
if (!icon) return DEFAULT_PACK_ICON;
if (VALID_IONICONS.has(icon)) {
return icon as IoniconsName;
// Check if it's a valid Ionicon name (supports the full Ionicons set)
if (isValidIonicon(icon)) {
return icon;
}
// Fall back to emoji mapping for legacy packs
return EMOJI_TO_IONICON[icon] || DEFAULT_PACK_ICON;
}
+1 -1
View File
@@ -5,4 +5,4 @@ export { usePacksStore } from "./packsStore";
export { useMessageStore } from "./messageStore";
export { useSubscriptionStore } from "./subscriptionStore";
export { useResponsesStore, groupResponses } from "./responsesStore";
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption } from "./responsesStore";
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption, DateSortOrder } from "./responsesStore";
+247 -12
View File
@@ -14,12 +14,24 @@ interface MatchState {
page: number;
hasMore: boolean;
error: string | null;
// Archive state
archivedMatches: Match[];
archivedMatchIds: Set<string>;
showArchived: boolean;
isLoadingArchived: boolean;
// Methods
fetchMatches: (refresh?: boolean) => Promise<void>;
markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>;
addMatch: (match: Match) => void;
updateMatchUnreadCount: (matchId: string, delta: number) => 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) => ({
@@ -31,6 +43,11 @@ export const useMatchStore = create<MatchState>((set, get) => ({
page: 0,
hasMore: true,
error: null,
// Archive state
archivedMatches: [],
archivedMatchIds: new Set<string>(),
showArchived: false,
isLoadingArchived: false,
fetchMatches: async (refresh = false) => {
const userId = useAuthStore.getState().user?.id;
@@ -57,7 +74,18 @@ export const useMatchStore = create<MatchState>((set, get) => ({
const from = currentPage * BATCH_SIZE;
const to = from + BATCH_SIZE - 1;
// Fetch total count on refresh
// Fetch archived match IDs for this user on refresh
let archivedMatchIds = state.archivedMatchIds;
if (refresh && userId) {
const { data: archives } = await supabase
.from("match_archives")
.select("match_id")
.eq("user_id", userId);
archivedMatchIds = new Set(archives?.map(a => a.match_id) ?? []);
}
// Fetch total count on refresh (excluding archived)
let totalCount = state.totalCount;
if (refresh) {
const { count, error: countError } = await supabase
@@ -66,7 +94,8 @@ export const useMatchStore = create<MatchState>((set, get) => ({
.eq("couple_id", coupleId);
if (!countError) {
totalCount = count ?? 0;
// Subtract archived count from total
totalCount = (count ?? 0) - archivedMatchIds.size;
}
}
@@ -81,9 +110,13 @@ export const useMatchStore = create<MatchState>((set, get) => ({
.range(from, to);
if (matchError) throw matchError;
if (!matches || matches.length === 0) {
// Filter out archived matches
const nonArchivedMatches = matches?.filter(m => !archivedMatchIds.has(m.id)) ?? [];
if (nonArchivedMatches.length === 0) {
if (refresh) {
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false });
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false, archivedMatchIds });
} else {
set({ isLoadingMore: false, hasMore: false });
}
@@ -91,7 +124,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
}
// Fetch all responses for these questions to determine who answered first
const questionIds = matches.map(m => m.question_id);
const questionIds = nonArchivedMatches.map(m => m.question_id);
let responses: any[] = [];
if (questionIds.length > 0) {
const { data } = await supabase
@@ -102,7 +135,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
}
// Fetch unread message counts per match
const matchIds = matches.map(m => m.id);
const matchIds = nonArchivedMatches.map(m => m.id);
let unreadCounts: Record<string, number> = {};
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,
responses: responses.filter(r => r.question_id === match.question_id),
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
const newMatchesInBatch = sortedData.filter((m) => m.is_new).length;
set((state) => ({
matches: refresh ? sortedData : [...state.matches, ...sortedData],
set((state) => {
// 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,
totalCount: refresh ? totalCount : state.totalCount,
isLoading: false,
isLoadingMore: false,
page: currentPage + 1,
hasMore: matches.length === BATCH_SIZE
}));
hasMore: nonArchivedMatches.length === BATCH_SIZE,
archivedMatchIds: refresh ? archivedMatchIds : state.archivedMatchIds,
};
});
} catch (err) {
console.error("Error fetching matches:", err);
set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false });
@@ -210,6 +256,195 @@ export const useMatchStore = create<MatchState>((set, get) => ({
},
clearMatches: () => {
set({ matches: [], newMatchesCount: 0, totalCount: null, isLoading: false, error: null, page: 0, hasMore: true, isLoadingMore: false });
set({
matches: [],
newMatchesCount: 0,
totalCount: null,
isLoading: false,
error: null,
page: 0,
hasMore: true,
isLoadingMore: false,
archivedMatches: [],
archivedMatchIds: new Set<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);
},
}));
+28 -5
View File
@@ -32,6 +32,7 @@ export interface UpdateResponseResult {
}
export type GroupByOption = "pack" | "date" | "answer";
export type DateSortOrder = "newest" | "oldest";
const BATCH_SIZE = 20;
@@ -40,6 +41,7 @@ interface ResponsesState {
isLoading: boolean;
isLoadingMore: boolean;
groupBy: GroupByOption;
dateSortOrder: DateSortOrder;
hasMore: boolean;
page: number;
totalCount: number | null;
@@ -50,6 +52,8 @@ interface ResponsesState {
confirmDelete?: boolean
) => Promise<UpdateResponseResult>;
setGroupBy: (groupBy: GroupByOption) => void;
setDateSortOrder: (order: DateSortOrder) => void;
toggleDateSortOrder: () => void;
clearResponses: () => void;
}
@@ -57,7 +61,8 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
responses: [],
isLoading: false,
isLoadingMore: false,
groupBy: "pack",
groupBy: "date",
dateSortOrder: "newest",
hasMore: true,
page: 0,
totalCount: null,
@@ -270,15 +275,26 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
set({ groupBy });
},
clearResponses: () => {
set({ responses: [], isLoading: false, groupBy: "pack", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
setDateSortOrder: (order: DateSortOrder) => {
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
export function groupResponses(
responses: ResponseWithQuestion[],
groupBy: GroupByOption
groupBy: GroupByOption,
dateSortOrder: DateSortOrder = "newest"
): { title: string; data: ResponseWithQuestion[] }[] {
if (groupBy === "pack") {
// Group by pack name
@@ -307,9 +323,16 @@ export function groupResponses(
}
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)
const groups = new Map<string, ResponseWithQuestion[]>();
responses.forEach((r) => {
sortedResponses.forEach((r) => {
const date = new Date(r.created_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
+8
View File
@@ -100,6 +100,14 @@ export interface MatchWithQuestion extends Match {
question: Question;
}
// Match archive (per-user)
export interface MatchArchive {
id: string;
match_id: string;
user_id: string;
archived_at: string;
}
// Pack with progress info
export interface PackProgress {
pack: QuestionPack;
+49
View File
@@ -98,18 +98,21 @@ export type Database = {
app_config: {
Row: {
answer_gap_threshold: number | null
daily_response_limit: number | null
id: string
updated_at: string | null
updated_by: string | null
}
Insert: {
answer_gap_threshold?: number | null
daily_response_limit?: number | null
id?: string
updated_at?: string | null
updated_by?: string | null
}
Update: {
answer_gap_threshold?: number | null
daily_response_limit?: number | null
id?: string
updated_at?: string | null
updated_by?: string | null
@@ -373,6 +376,42 @@ export type Database = {
}
Relationships: []
}
match_archives: {
Row: {
id: string
match_id: string
user_id: string
archived_at: string | null
}
Insert: {
id?: string
match_id: string
user_id: string
archived_at?: string | null
}
Update: {
id?: string
match_id?: string
user_id?: string
archived_at?: string | null
}
Relationships: [
{
foreignKeyName: "match_archives_match_id_fkey"
columns: ["match_id"]
isOneToOne: false
referencedRelation: "matches"
referencedColumns: ["id"]
},
{
foreignKeyName: "match_archives_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "users"
referencedColumns: ["id"]
},
]
}
matches: {
Row: {
couple_id: string
@@ -925,6 +964,16 @@ export type Database = {
}[]
}
get_auth_user_couple_id: { Args: never; Returns: string }
get_daily_response_limit_status: {
Args: never
Returns: {
is_blocked: boolean
limit_value: number
remaining: number
reset_at: string
responses_today: number
}[]
}
get_feature_interest_counts: {
Args: never
Returns: {
+209 -17
View File
@@ -9,6 +9,174 @@ const corsHeaders = {
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
const DEFAULT_WHITELIST = [
"ok",
"okay",
"yes",
"no",
"maybe",
"thanks",
"thank you",
"lol",
"lmao",
"good night",
"goodnight",
"good morning",
"love you",
];
const DEFAULT_KEYWORD_TRIGGERS = [
"suicide",
"kill myself",
"self harm",
"kill you",
"hurt you",
"rape",
"porn",
"nude",
"explicit",
"stab",
"shoot",
];
function parseBoolean(value, defaultValue = false) {
if (value === null || value === undefined) return defaultValue;
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
}
return defaultValue;
}
function parseNumber(value, defaultValue) {
if (value === null || value === undefined) return defaultValue;
if (typeof value === "number" && Number.isFinite(value)) return value;
const parsed = Number(String(value).trim());
return Number.isFinite(parsed) ? parsed : defaultValue;
}
function parseStringArray(value) {
if (!value) return [];
if (Array.isArray(value)) {
return value
.filter((item) => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean);
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return [];
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed
.filter((item) => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean);
}
} catch {
// Fall through to delimiter parsing
}
}
return trimmed
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
return [];
}
function normalizeList(values) {
return Array.from(
new Set(
values
.map((value) => value.trim().toLowerCase())
.filter(Boolean)
)
);
}
function getHeuristicConfig(config) {
const enabled = parseBoolean(config?.heuristics_enabled, false);
const minTextLength = parseNumber(config?.heuristic_min_text_length, 12);
const whitelistMaxLength = parseNumber(config?.heuristic_whitelist_max_length, 30);
const skipIfNoAlnum = parseBoolean(config?.heuristic_skip_if_no_alnum, true);
const skipMediaWithoutText = parseBoolean(config?.heuristic_skip_media_without_text, false);
const recordReason = parseBoolean(config?.heuristic_record_reason, false);
const useDefaultWhitelist = parseBoolean(config?.heuristic_use_default_whitelist, true);
const useDefaultKeywords = parseBoolean(config?.heuristic_use_default_keywords, true);
const whitelist = parseStringArray(config?.heuristic_whitelist);
const keywordTriggers = parseStringArray(config?.heuristic_keyword_triggers);
const normalizedWhitelist = normalizeList(
useDefaultWhitelist ? DEFAULT_WHITELIST.concat(whitelist) : whitelist
);
const normalizedKeywordTriggers = normalizeList(
useDefaultKeywords ? DEFAULT_KEYWORD_TRIGGERS.concat(keywordTriggers) : keywordTriggers
);
return {
enabled,
minTextLength,
whitelistMaxLength,
skipIfNoAlnum,
skipMediaWithoutText,
recordReason,
whitelist: normalizedWhitelist,
keywordTriggers: normalizedKeywordTriggers,
};
}
function evaluateHeuristics(text, hasMedia, config) {
if (!config.enabled) {
return { shouldCallAi: true, reason: "disabled" };
}
const normalized = (text || "").trim();
const lower = normalized.toLowerCase();
const hasText = normalized.length > 0;
const hasAlnum = /[a-z0-9]/i.test(normalized);
if (config.keywordTriggers.length > 0) {
const keywordHit = config.keywordTriggers.some((keyword) => lower.includes(keyword));
if (keywordHit) {
return { shouldCallAi: true, reason: "keyword_trigger" };
}
}
if (!hasMedia && !hasText) {
return { shouldCallAi: false, reason: "no_content" };
}
if (!hasMedia) {
if (config.skipIfNoAlnum && !hasAlnum) {
return { shouldCallAi: false, reason: "low_signal" };
}
if (normalized.length <= config.whitelistMaxLength) {
const isWhitelisted = config.whitelist.includes(lower);
if (isWhitelisted) {
return { shouldCallAi: false, reason: "whitelist" };
}
}
if (normalized.length < config.minTextLength) {
return { shouldCallAi: false, reason: "short_text" };
}
}
if (hasMedia && config.skipMediaWithoutText && !hasText) {
return { shouldCallAi: false, reason: "media_without_text" };
}
return { shouldCallAi: true, reason: "default" };
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
@@ -42,6 +210,8 @@ Deno.serve(async (req) => {
return new Response(JSON.stringify({ message: "Classifier disabled" }), { status: 200, headers: corsHeaders });
}
const heuristicConfig = getHeuristicConfig(config);
const apiKey = config.openrouter_api_key || Deno.env.get("OPENROUTER_API_KEY");
if (!apiKey) {
console.error("OpenRouter API Key missing");
@@ -64,15 +234,22 @@ Deno.serve(async (req) => {
let decryptedText = message.content;
let mediaBase64: string | null = null;
let mediaMimeType: string | null = null;
const hasMedia = !!message.media_path && message.media_type === "image";
const keysMetadata = message.keys_metadata as KeysMetadata | null;
const encryptionIv = message.encryption_iv as string | null;
let aesKey: CryptoKey | null = null;
if (version === 2) {
const keysMetadata = message.keys_metadata as KeysMetadata;
const encryptionIv = message.encryption_iv;
if (version === 2 && (message.encrypted_content || hasMedia)) {
if (!keysMetadata || !encryptionIv) {
return new Response(JSON.stringify({ error: "Missing encryption metadata" }), { status: 400, headers: corsHeaders });
}
}
const getAesKey = async () => {
if (aesKey) return aesKey;
if (!keysMetadata || !encryptionIv) {
throw new Error("Missing encryption metadata");
}
const adminPrivateKeyJwkStr = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
if (!adminPrivateKeyJwkStr) {
throw new Error("ADMIN_PRIVATE_KEY_JWK not set");
@@ -81,7 +258,7 @@ Deno.serve(async (req) => {
const adminPrivateKey = JSON.parse(adminPrivateKeyJwkStr);
const rawAesKey = await unwrapWithAdminKey(keysMetadata.admin_wrapped_key, adminPrivateKey);
const aesKey = await crypto.subtle.importKey(
aesKey = await crypto.subtle.importKey(
"raw",
rawAesKey,
{ name: "AES-GCM" },
@@ -89,18 +266,35 @@ Deno.serve(async (req) => {
["decrypt"]
);
// Decrypt Text
if (message.encrypted_content) {
return aesKey;
};
if (version === 2 && message.encrypted_content) {
const key = await getAesKey();
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
aesKey,
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv as string) },
key,
base64ToUint8Array(message.encrypted_content)
);
decryptedText = new TextDecoder().decode(plaintext);
}
// Decrypt Media (Image only for now)
if (message.media_path && message.media_type === "image") {
const heuristicDecision = evaluateHeuristics(decryptedText, hasMedia, heuristicConfig);
if (heuristicConfig.enabled && !heuristicDecision.shouldCallAi) {
console.log(`Heuristic skip for message ${messageId}: ${heuristicDecision.reason}`);
await updateStatus(
supabaseAdmin,
messageId,
"safe",
heuristicConfig.recordReason ? heuristicDecision.reason : null,
"Neutral"
);
return new Response(JSON.stringify({ status: "safe" }), { headers: corsHeaders });
}
if (hasMedia) {
if (version === 2) {
const key = await getAesKey();
const { data: mediaBlob, error: downloadError } = await supabaseAdmin.storage
.from("chat-media")
.download(normalizeChatMediaPath(message.media_path));
@@ -108,20 +302,18 @@ Deno.serve(async (req) => {
if (!downloadError && mediaBlob) {
const encryptedBytes = await mediaBlob.arrayBuffer();
const decryptedBytes = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
aesKey,
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv as string) },
key,
encryptedBytes
);
mediaBase64 = arrayBufferToBase64(decryptedBytes);
mediaMimeType = "image/jpeg"; // Default or infer?
}
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);
@@ -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
const { data: response, error: responseError } = await supabase
.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.';
+1
View File
@@ -4,4 +4,5 @@ NEXT_PUBLIC_POSTHOG_KEY=phc_your_project_key_here
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
# Supabase
# Production: https://ckjcrkjpmhqhiucifukx.supabase.co
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
+2
View File
@@ -6,9 +6,11 @@ WORKDIR /app
# Build args for Next.js public env vars (inlined at build time)
ARG NEXT_PUBLIC_POSTHOG_KEY
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_HOST=$NEXT_PUBLIC_POSTHOG_HOST
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
# Install dependencies
COPY package*.json ./
+1
View File
@@ -8,6 +8,7 @@ services:
args:
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- 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:
- NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1
+625
View File
@@ -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`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 613 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 863 KiB

Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more