mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
Initial commit
This commit is contained in:
commit
4d53d83769
73 files changed
+37728
No files matched your search
Executable
+55
@@ -0,0 +1,55 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.next/
|
||||||
|
.expo/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Supabase
|
||||||
|
.supabase/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Turbo
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
npm_install_log.txt
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.pem
|
||||||
|
.cache/
|
||||||
|
.temp/
|
||||||
|
.tmp/
|
||||||
|
*.tmp
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Sauci
|
||||||
|
|
||||||
|
A couples intimacy app featuring swipeable question packs with partner matching.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
sauci/
|
||||||
|
├── apps/
|
||||||
|
│ ├── mobile/ # Expo React Native app
|
||||||
|
│ ├── admin/ # React Admin dashboard
|
||||||
|
│ └── supabase/ # Supabase project (migrations, edge functions)
|
||||||
|
├── packages/
|
||||||
|
│ └── shared/ # Shared TypeScript types
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- npm 10+
|
||||||
|
- Supabase CLI (`npm install -g supabase`)
|
||||||
|
- Expo CLI (`npm install -g expo-cli`)
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Mobile**: Expo (React Native)
|
||||||
|
- **Backend**: Supabase Cloud (PostgreSQL, Auth, Edge Functions, Realtime)
|
||||||
|
- **Admin**: React Admin
|
||||||
|
- **Monorepo**: Turborepo
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY turbo.json ./
|
||||||
|
COPY apps/admin/package.json ./apps/admin/
|
||||||
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
|
|
||||||
|
# Install dependencies (only what's needed for admin)
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build shared package first
|
||||||
|
RUN npx turbo run build --filter=@sauci/shared
|
||||||
|
|
||||||
|
# Build admin app with env vars
|
||||||
|
ARG VITE_SUPABASE_URL
|
||||||
|
ARG VITE_SUPABASE_ANON_KEY
|
||||||
|
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
|
||||||
|
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
|
||||||
|
|
||||||
|
RUN npx turbo run build --filter=@sauci/admin
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/apps/admin/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Sauci Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@sauci/admin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.39.0",
|
||||||
|
"ra-supabase": "^1.0.0",
|
||||||
|
"@sauci/shared": "*",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-admin": "^4.16.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import { Admin, Resource } from "react-admin";
|
||||||
|
import { dataProvider } from "./providers/supabaseProvider";
|
||||||
|
import { PackList, PackEdit, PackCreate } from "./resources/packs";
|
||||||
|
import { QuestionList, QuestionEdit, QuestionCreate } from "./resources/questions";
|
||||||
|
import { ProfileList, ResponseList } from "./resources/users";
|
||||||
|
import { authProvider } from "./providers/authProvider";
|
||||||
|
|
||||||
|
const App = () => (
|
||||||
|
<Admin dataProvider={dataProvider} authProvider={authProvider} requireAuth>
|
||||||
|
<Resource
|
||||||
|
name="question_packs"
|
||||||
|
list={PackList}
|
||||||
|
edit={PackEdit}
|
||||||
|
create={PackCreate}
|
||||||
|
recordRepresentation="name"
|
||||||
|
/>
|
||||||
|
<Resource
|
||||||
|
name="questions"
|
||||||
|
list={QuestionList}
|
||||||
|
edit={QuestionEdit}
|
||||||
|
create={QuestionCreate}
|
||||||
|
/>
|
||||||
|
<Resource
|
||||||
|
name="profiles"
|
||||||
|
list={ProfileList}
|
||||||
|
/>
|
||||||
|
<Resource
|
||||||
|
name="responses"
|
||||||
|
list={ResponseList}
|
||||||
|
/>
|
||||||
|
<Resource name="couples" />
|
||||||
|
</Admin>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default App;
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
export const supabaseConfig = {
|
||||||
|
url: import.meta.env.VITE_SUPABASE_URL,
|
||||||
|
anonKey: import.meta.env.VITE_SUPABASE_ANON_KEY,
|
||||||
|
};
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import { AuthProvider } from 'react-admin';
|
||||||
|
import { supabase } from '../config';
|
||||||
|
|
||||||
|
export const authProvider: AuthProvider = {
|
||||||
|
login: async ({ username, password }) => {
|
||||||
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
|
email: username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
logout: async () => {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
checkError: async (error) => {
|
||||||
|
if (error.status === 401 || error.status === 403) {
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
checkAuth: async () => {
|
||||||
|
const session = await supabase.auth.getSession();
|
||||||
|
return session.data.session ? Promise.resolve() : Promise.reject();
|
||||||
|
},
|
||||||
|
getPermissions: () => Promise.resolve(),
|
||||||
|
};
|
||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { supabaseConfig } from '../config';
|
||||||
|
|
||||||
|
export const supabase = createClient(
|
||||||
|
supabaseConfig.url,
|
||||||
|
supabaseConfig.anonKey
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom data provider wrapper if needed for ra-data-supabase
|
||||||
|
import { supabaseDataProvider } from 'ra-supabase';
|
||||||
|
|
||||||
|
export const dataProvider = supabaseDataProvider(
|
||||||
|
supabase,
|
||||||
|
{
|
||||||
|
resources: {
|
||||||
|
question_packs: 'question_packs',
|
||||||
|
questions: 'questions',
|
||||||
|
profiles: 'profiles',
|
||||||
|
couples: 'couples'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
import { List, Datagrid, TextField, BooleanField, EditButton, Create, SimpleForm, TextInput, BooleanInput, Edit } from 'react-admin';
|
||||||
|
|
||||||
|
export const PackList = () => (
|
||||||
|
<List>
|
||||||
|
<Datagrid rowClick="edit">
|
||||||
|
<TextField source="name" />
|
||||||
|
<TextField source="description" />
|
||||||
|
<BooleanField source="is_premium" />
|
||||||
|
<BooleanField source="is_public" />
|
||||||
|
<EditButton />
|
||||||
|
</Datagrid>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PackEdit = () => (
|
||||||
|
<Edit>
|
||||||
|
<SimpleForm>
|
||||||
|
<TextInput source="name" />
|
||||||
|
<TextInput source="description" multiline />
|
||||||
|
<TextInput source="icon" />
|
||||||
|
<BooleanInput source="is_premium" />
|
||||||
|
<BooleanInput source="is_public" />
|
||||||
|
<TextInput source="sort_order" />
|
||||||
|
</SimpleForm>
|
||||||
|
</Edit>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PackCreate = () => (
|
||||||
|
<Create>
|
||||||
|
<SimpleForm>
|
||||||
|
<TextInput source="name" />
|
||||||
|
<TextInput source="description" multiline />
|
||||||
|
<TextInput source="icon" />
|
||||||
|
<BooleanInput source="is_premium" />
|
||||||
|
<BooleanInput source="is_public" />
|
||||||
|
<TextInput source="sort_order" />
|
||||||
|
</SimpleForm>
|
||||||
|
</Create>
|
||||||
|
);
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
import { List, Datagrid, TextField, ReferenceField, NumberField, EditButton, Create, SimpleForm, TextInput, NumberInput, ReferenceInput, SelectInput, Edit } from 'react-admin';
|
||||||
|
|
||||||
|
export const QuestionList = () => (
|
||||||
|
<List>
|
||||||
|
<Datagrid rowClick="edit">
|
||||||
|
<TextField source="text" />
|
||||||
|
<ReferenceField source="pack_id" reference="question_packs">
|
||||||
|
<TextField source="name" />
|
||||||
|
</ReferenceField>
|
||||||
|
<NumberField source="intensity" />
|
||||||
|
<EditButton />
|
||||||
|
</Datagrid>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const QuestionEdit = () => (
|
||||||
|
<Edit>
|
||||||
|
<SimpleForm>
|
||||||
|
<ReferenceInput source="pack_id" reference="question_packs">
|
||||||
|
<SelectInput optionText="name" />
|
||||||
|
</ReferenceInput>
|
||||||
|
<TextInput source="text" multiline fullWidth />
|
||||||
|
<NumberInput source="intensity" min={1} max={5} />
|
||||||
|
</SimpleForm>
|
||||||
|
</Edit>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const QuestionCreate = () => (
|
||||||
|
<Create>
|
||||||
|
<SimpleForm>
|
||||||
|
<ReferenceInput source="pack_id" reference="question_packs">
|
||||||
|
<SelectInput optionText="name" />
|
||||||
|
</ReferenceInput>
|
||||||
|
<TextInput source="text" multiline fullWidth />
|
||||||
|
<NumberInput source="intensity" min={1} max={5} />
|
||||||
|
</SimpleForm>
|
||||||
|
</Create>
|
||||||
|
);
|
||||||
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import { List, Datagrid, TextField, BooleanField, DateField, ReferenceField } from 'react-admin';
|
||||||
|
|
||||||
|
export const ProfileList = () => (
|
||||||
|
<List>
|
||||||
|
<Datagrid>
|
||||||
|
<TextField source="name" />
|
||||||
|
<TextField source="email" />
|
||||||
|
<BooleanField source="is_premium" />
|
||||||
|
<ReferenceField source="couple_id" reference="couples">
|
||||||
|
<TextField source="id" />
|
||||||
|
</ReferenceField>
|
||||||
|
<DateField source="created_at" />
|
||||||
|
</Datagrid>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ResponseList = () => (
|
||||||
|
<List>
|
||||||
|
<Datagrid>
|
||||||
|
<ReferenceField source="user_id" reference="profiles">
|
||||||
|
<TextField source="name" />
|
||||||
|
</ReferenceField>
|
||||||
|
<ReferenceField source="question_id" reference="questions">
|
||||||
|
<TextField source="text" />
|
||||||
|
</ReferenceField>
|
||||||
|
<TextField source="answer" />
|
||||||
|
<DateField source="created_at" />
|
||||||
|
</Datagrid>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 3001,
|
||||||
|
},
|
||||||
|
});
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||||
|
# The following patterns were generated by expo-cli
|
||||||
|
|
||||||
|
expo-env.d.ts
|
||||||
|
# @end expo-cli
|
||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Sauci",
|
||||||
|
"slug": "sauci",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"scheme": "sauci",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#1a1a2e"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": false,
|
||||||
|
"bundleIdentifier": "com.sauci.app",
|
||||||
|
"infoPlist": {
|
||||||
|
"UIBackgroundModes": [
|
||||||
|
"remote-notification"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#1a1a2e"
|
||||||
|
},
|
||||||
|
"package": "com.sauci.app",
|
||||||
|
"permissions": [
|
||||||
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
|
"VIBRATE"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro",
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-notifications",
|
||||||
|
{
|
||||||
|
"icon": "./assets/notification-icon.png",
|
||||||
|
"color": "#e94560"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-secure-store"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {
|
||||||
|
"origin": false
|
||||||
|
},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "REPLACE_WITH_EAS_PROJECT_ID"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+117
@@ -0,0 +1,117 @@
|
|||||||
|
import { Tabs, Redirect } from "expo-router";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { View, ActivityIndicator, StyleSheet } from "react-native";
|
||||||
|
import { useAuthStore, useMatchStore } from "../../src/store";
|
||||||
|
|
||||||
|
export default function AppLayout() {
|
||||||
|
const { isAuthenticated, isLoading } = useAuthStore();
|
||||||
|
const { newMatchesCount } = useMatchStore();
|
||||||
|
|
||||||
|
// Show loading state while checking authentication
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#e94560" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to login if not authenticated
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Redirect href="/(auth)/login" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderTopColor: "#0f3460",
|
||||||
|
borderTopWidth: 1,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 8,
|
||||||
|
height: 60,
|
||||||
|
},
|
||||||
|
tabBarActiveTintColor: "#e94560",
|
||||||
|
tabBarInactiveTintColor: "#666",
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Home",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="home" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="packs"
|
||||||
|
options={{
|
||||||
|
title: "Packs",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="layers" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="swipe"
|
||||||
|
options={{
|
||||||
|
title: "Play",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="flame" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="matches"
|
||||||
|
options={{
|
||||||
|
title: "Matches",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="heart" size={size} color={color} />
|
||||||
|
),
|
||||||
|
tabBarBadge: newMatchesCount > 0 ? newMatchesCount : undefined,
|
||||||
|
tabBarBadgeStyle: {
|
||||||
|
backgroundColor: "#e94560",
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="profile"
|
||||||
|
options={{
|
||||||
|
title: "Profile",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="person" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="pairing"
|
||||||
|
options={{
|
||||||
|
href: null, // Hide from tab bar
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="chat/[id]"
|
||||||
|
options={{
|
||||||
|
href: null, // Hide from tab bar
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#1a1a2e",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, Image, ActivityIndicator, Alert, AppState } from "react-native";
|
||||||
|
import { useLocalSearchParams, useRouter, Stack } from "expo-router";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { supabase } from "../../../src/lib/supabase";
|
||||||
|
import { useAuthStore } from "../../../src/store";
|
||||||
|
import * as ImagePicker from "expo-image-picker";
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import { decode } from 'base64-arraybuffer';
|
||||||
|
import { Database } from "../../../src/types/supabase";
|
||||||
|
|
||||||
|
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||||
|
|
||||||
|
export default function ChatScreen() {
|
||||||
|
const { id } = useLocalSearchParams();
|
||||||
|
const matchId = id as string;
|
||||||
|
const router = useRouter();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [match, setMatch] = useState<any>(null);
|
||||||
|
const [inputText, setInputText] = useState("");
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [partnerTyping, setPartnerTyping] = useState(false);
|
||||||
|
const flatListRef = useRef<FlatList>(null);
|
||||||
|
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!matchId) return;
|
||||||
|
|
||||||
|
// Fetch match context (question and responses)
|
||||||
|
const fetchMatch = async () => {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("matches")
|
||||||
|
.select("*, question:questions(*)")
|
||||||
|
.eq("id", matchId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
// Fetch responses for both users in this couple for this question
|
||||||
|
const { data: responses } = await supabase
|
||||||
|
.from("responses")
|
||||||
|
.select("*, profiles(name)")
|
||||||
|
.eq("question_id", data.question_id)
|
||||||
|
.eq("couple_id", data.couple_id);
|
||||||
|
|
||||||
|
setMatch({ ...data, responses: responses || [] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchMatch();
|
||||||
|
|
||||||
|
// Fetch initial messages and mark as read
|
||||||
|
const fetchMessagesAndMarkRead = async () => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("messages")
|
||||||
|
.select("*")
|
||||||
|
.eq("match_id", matchId)
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setMessages(data);
|
||||||
|
|
||||||
|
// Identify unread messages from partner
|
||||||
|
const unreadIds = data
|
||||||
|
.filter(m => m.user_id !== user?.id && !m.read_at)
|
||||||
|
.map(m => m.id);
|
||||||
|
|
||||||
|
if (unreadIds.length > 0) {
|
||||||
|
await supabase
|
||||||
|
.from("messages")
|
||||||
|
.update({ read_at: new Date().toISOString() })
|
||||||
|
.in("id", unreadIds);
|
||||||
|
|
||||||
|
// Optimistically update local state
|
||||||
|
setMessages(prev => prev.map(m =>
|
||||||
|
unreadIds.includes(m.id) ? { ...m, read_at: new Date().toISOString() } : m
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMessagesAndMarkRead();
|
||||||
|
|
||||||
|
// Subscribe to new messages, updates (read receipts), and typing
|
||||||
|
const channel = supabase
|
||||||
|
.channel(`chat:${matchId}`)
|
||||||
|
.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{
|
||||||
|
event: "INSERT",
|
||||||
|
schema: "public",
|
||||||
|
table: "messages",
|
||||||
|
filter: `match_id=eq.${matchId}`,
|
||||||
|
},
|
||||||
|
async (payload) => {
|
||||||
|
const newMessage = payload.new as Message;
|
||||||
|
|
||||||
|
// If receiving a message from partner, mark as read immediately since we are in the chat
|
||||||
|
if (newMessage.user_id !== user?.id) {
|
||||||
|
await supabase
|
||||||
|
.from("messages")
|
||||||
|
.update({ read_at: new Date().toISOString() })
|
||||||
|
.eq("id", newMessage.id);
|
||||||
|
newMessage.read_at = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((prev) => [newMessage, ...prev]);
|
||||||
|
setPartnerTyping(false); // Stop typing indicator if they sent a message
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{
|
||||||
|
event: "UPDATE",
|
||||||
|
schema: "public",
|
||||||
|
table: "messages",
|
||||||
|
filter: `match_id=eq.${matchId}`,
|
||||||
|
},
|
||||||
|
(payload) => {
|
||||||
|
const updatedMessage = payload.new as Message;
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map(m => m.id === updatedMessage.id ? updatedMessage : m)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.on("broadcast", { event: "typing" }, (payload) => {
|
||||||
|
if (payload.payload.userId !== user?.id) {
|
||||||
|
setPartnerTyping(true);
|
||||||
|
|
||||||
|
// Auto-hide typing indicator after 3s
|
||||||
|
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
||||||
|
typingTimeoutRef.current = setTimeout(() => {
|
||||||
|
setPartnerTyping(false);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
supabase.removeChannel(channel);
|
||||||
|
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
||||||
|
};
|
||||||
|
}, [matchId]);
|
||||||
|
|
||||||
|
const handleTyping = (text: string) => {
|
||||||
|
setInputText(text);
|
||||||
|
|
||||||
|
if (text.length > 0) {
|
||||||
|
supabase.channel(`chat:${matchId}`).send({
|
||||||
|
type: "broadcast",
|
||||||
|
event: "typing",
|
||||||
|
payload: { userId: user?.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!inputText.trim()) return;
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const content = inputText.trim();
|
||||||
|
setInputText("");
|
||||||
|
|
||||||
|
const { error } = await supabase.from("messages").insert({
|
||||||
|
match_id: matchId,
|
||||||
|
user_id: user.id,
|
||||||
|
content: content,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert("Error", "Failed to send message");
|
||||||
|
setInputText(content);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePickImage = async () => {
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||||
|
allowsEditing: true,
|
||||||
|
quality: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled) {
|
||||||
|
uploadImage(result.assets[0].uri);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadImage = async (uri: string) => {
|
||||||
|
if (!user) return;
|
||||||
|
setUploading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let fileBody;
|
||||||
|
let ext = "jpg";
|
||||||
|
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
const response = await fetch(uri);
|
||||||
|
const blob = await response.blob();
|
||||||
|
fileBody = blob;
|
||||||
|
|
||||||
|
if (blob.type === 'image/png') ext = 'png';
|
||||||
|
else if (blob.type === 'image/jpeg' || blob.type === 'image/jpg') ext = 'jpg';
|
||||||
|
else if (blob.type === 'image/gif') ext = 'gif';
|
||||||
|
else if (blob.type === 'image/webp') ext = 'webp';
|
||||||
|
} else {
|
||||||
|
const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 });
|
||||||
|
fileBody = decode(base64);
|
||||||
|
|
||||||
|
const uriExt = uri.split('.').pop();
|
||||||
|
if (uriExt && uriExt !== uri) {
|
||||||
|
ext = uriExt.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
|
||||||
|
const contentType = `image/${ext === "jpg" ? "jpeg" : ext}`;
|
||||||
|
|
||||||
|
const { error: uploadError } = await supabase.storage
|
||||||
|
.from("chat-media")
|
||||||
|
.upload(fileName, fileBody, {
|
||||||
|
contentType,
|
||||||
|
upsert: false
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uploadError) throw uploadError;
|
||||||
|
|
||||||
|
const { data: { publicUrl } } = supabase.storage
|
||||||
|
.from("chat-media")
|
||||||
|
.getPublicUrl(fileName);
|
||||||
|
|
||||||
|
await supabase.from("messages").insert({
|
||||||
|
match_id: matchId,
|
||||||
|
user_id: user.id,
|
||||||
|
media_path: publicUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert("Error", "Failed to upload image");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revealMessage = async (messageId: string) => {
|
||||||
|
// Optimistic update
|
||||||
|
setMessages(prev => prev.map(m =>
|
||||||
|
m.id === messageId ? { ...m, media_viewed_at: new Date().toISOString() } : m
|
||||||
|
));
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("messages")
|
||||||
|
.update({ media_viewed_at: new Date().toISOString() })
|
||||||
|
.eq("id", messageId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Failed to mark as viewed", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const renderMessage = ({ item }: { item: Message }) => {
|
||||||
|
const isMe = item.user_id === user?.id;
|
||||||
|
return (
|
||||||
|
<View style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}>
|
||||||
|
<View style={[styles.bubble, isMe ? styles.myBubble : styles.theirBubble]}>
|
||||||
|
{item.media_path ? (
|
||||||
|
<View>
|
||||||
|
<Image
|
||||||
|
source={{ uri: item.media_path }}
|
||||||
|
style={styles.messageImage}
|
||||||
|
blurRadius={(!isMe && !item.media_viewed_at) ? 20 : 0}
|
||||||
|
/>
|
||||||
|
{(!isMe && !item.media_viewed_at) && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.revealOverlay}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
onPress={() => revealMessage(item.id)}
|
||||||
|
>
|
||||||
|
<View style={styles.revealContent}>
|
||||||
|
<Ionicons name="eye-off-outline" size={32} color="#fff" />
|
||||||
|
<Text style={styles.revealText}>Tap to reveal</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.messageText}>{item.content}</Text>
|
||||||
|
)}
|
||||||
|
<View style={styles.metaContainer}>
|
||||||
|
<Text style={[styles.timestamp, isMe ? styles.myTimestamp : styles.theirTimestamp]}>
|
||||||
|
{new Date(item.created_at!).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</Text>
|
||||||
|
{isMe && (
|
||||||
|
<Ionicons
|
||||||
|
name={item.read_at ? "checkmark-done-outline" : "checkmark-outline"}
|
||||||
|
size={14}
|
||||||
|
color={item.read_at ? "#4cd137" : "rgba(255,255,255,0.7)"}
|
||||||
|
style={styles.readIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Stack.Screen options={{
|
||||||
|
title: "Chat",
|
||||||
|
headerTitle: match?.question?.text ? () => (
|
||||||
|
<View style={styles.headerTitleContainer}>
|
||||||
|
<Text style={styles.headerTitle} numberOfLines={2}>
|
||||||
|
{match.question.text}
|
||||||
|
{match.question.partner_text ? ` & ${match.question.partner_text}` : ""}
|
||||||
|
</Text>
|
||||||
|
{match.responses && match.responses.length > 0 && (
|
||||||
|
<View style={styles.answersContainer}>
|
||||||
|
{match.responses.map((response) => (
|
||||||
|
<Text key={response.user_id} style={styles.answerText}>
|
||||||
|
{response.profiles?.name || 'You'}: {response.answer}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : "Chat",
|
||||||
|
headerStyle: { backgroundColor: "#16213e" },
|
||||||
|
headerTintColor: "#fff",
|
||||||
|
headerBackTitleVisible: false,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.keyboardAvoiding}
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
|
||||||
|
>
|
||||||
|
{/* Sticky top bar with question and answers */}
|
||||||
|
{match?.question && (
|
||||||
|
<View style={styles.stickyTopBar}>
|
||||||
|
<Text style={styles.stickyQuestionText}>
|
||||||
|
{match.question.text}
|
||||||
|
{match.question.partner_text ? ` & ${match.question.partner_text}` : ""}
|
||||||
|
</Text>
|
||||||
|
{match.responses && match.responses.length > 0 && (
|
||||||
|
<View style={styles.stickyAnswersContainer}>
|
||||||
|
{match.responses.map((response) => (
|
||||||
|
<Text key={response.user_id} style={styles.stickyAnswerText}>
|
||||||
|
{response.user_id === user?.id ? 'You' : (response.profiles?.name || 'Partner')}: {response.answer}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
ref={flatListRef}
|
||||||
|
data={messages}
|
||||||
|
renderItem={renderMessage}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
inverted
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
ListHeaderComponent={
|
||||||
|
partnerTyping ? (
|
||||||
|
<View style={styles.typingContainer}>
|
||||||
|
<Text style={styles.typingText}>Partner is typing...</Text>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<TouchableOpacity onPress={handlePickImage} disabled={uploading} style={styles.attachButton}>
|
||||||
|
{uploading ? (
|
||||||
|
<ActivityIndicator color="#e94560" size="small" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="image-outline" size={24} color="#e94560" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={inputText}
|
||||||
|
onChangeText={handleTyping}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
placeholderTextColor="#666"
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={handleSend} disabled={!inputText.trim()} style={styles.sendButton}>
|
||||||
|
<Ionicons
|
||||||
|
name="send"
|
||||||
|
size={24}
|
||||||
|
color={inputText.trim() ? "#e94560" : "#666"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#1a1a2e",
|
||||||
|
},
|
||||||
|
keyboardAvoiding: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
messageRow: {
|
||||||
|
marginBottom: 16,
|
||||||
|
flexDirection: "row",
|
||||||
|
},
|
||||||
|
myMessageRow: {
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
theirMessageRow: {
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
},
|
||||||
|
bubble: {
|
||||||
|
maxWidth: "75%",
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 20,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
myBubble: {
|
||||||
|
backgroundColor: "#e94560",
|
||||||
|
borderBottomRightRadius: 4,
|
||||||
|
},
|
||||||
|
theirBubble: {
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#0f3460",
|
||||||
|
borderBottomLeftRadius: 4,
|
||||||
|
},
|
||||||
|
messageText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
messageImage: {
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.2)",
|
||||||
|
},
|
||||||
|
metaContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
myTimestamp: {
|
||||||
|
color: "rgba(255, 255, 255, 0.7)",
|
||||||
|
},
|
||||||
|
theirTimestamp: {
|
||||||
|
color: "rgba(255, 255, 255, 0.4)",
|
||||||
|
},
|
||||||
|
readIcon: {
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
inputContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 12,
|
||||||
|
paddingBottom: Platform.OS === "ios" ? 34 : 12,
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: "#0f3460",
|
||||||
|
},
|
||||||
|
attachButton: {
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#0f3460",
|
||||||
|
borderRadius: 24,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
color: "#fff",
|
||||||
|
maxHeight: 120,
|
||||||
|
marginHorizontal: 8,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
sendButton: {
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
headerTitleContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
maxWidth: 200,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "bold",
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
answersContainer: {
|
||||||
|
marginTop: 4,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
answerText: {
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
typingContainer: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
typingText: {
|
||||||
|
color: "#666",
|
||||||
|
fontStyle: "italic",
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
revealOverlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.3)",
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
revealContent: {
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
revealText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
stickyTopBar: {
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#0f3460",
|
||||||
|
padding: 16,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 12,
|
||||||
|
},
|
||||||
|
stickyQuestionText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "bold",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
stickyAnswersContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
stickyAnswerText: {
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 4,
|
||||||
|
}
|
||||||
|
});
|
||||||
Executable
+206
@@ -0,0 +1,206 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from "react-native";
|
||||||
|
import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
|
||||||
|
export default function HomeScreen() {
|
||||||
|
const { user, partner } = useAuthStore();
|
||||||
|
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
|
||||||
|
const { packs, fetchPacks } = usePacksStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMatches();
|
||||||
|
fetchPacks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const recentMatches = matches.slice(0, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.greeting}>
|
||||||
|
Hey, {user?.name || "there"} 👋
|
||||||
|
</Text>
|
||||||
|
{partner && (
|
||||||
|
<Text style={styles.partnerText}>
|
||||||
|
Paired with {partner.name || partner.email || 'your partner'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<View style={styles.statsContainer}>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Ionicons name="heart" size={24} color="#e94560" />
|
||||||
|
<Text style={styles.statNumber}>{matches.length}</Text>
|
||||||
|
<Text style={styles.statLabel}>Matches</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Ionicons name="layers" size={24} color="#e94560" />
|
||||||
|
<Text style={styles.statNumber}>{packs.length}</Text>
|
||||||
|
<Text style={styles.statLabel}>Packs</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Ionicons name="sparkles" size={24} color="#e94560" />
|
||||||
|
<Text style={styles.statNumber}>{newMatchesCount}</Text>
|
||||||
|
<Text style={styles.statLabel}>New</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Start Playing CTA */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.ctaCard}
|
||||||
|
onPress={() => router.push("/(app)/swipe")}
|
||||||
|
>
|
||||||
|
<View style={styles.ctaContent}>
|
||||||
|
<Text style={styles.ctaTitle}>Ready to explore?</Text>
|
||||||
|
<Text style={styles.ctaSubtitle}>
|
||||||
|
Swipe through questions and discover what you both enjoy
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="arrow-forward" size={24} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Recent Matches */}
|
||||||
|
{recentMatches.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>Recent Matches</Text>
|
||||||
|
<TouchableOpacity onPress={() => router.push("/(app)/matches")}>
|
||||||
|
<Text style={styles.seeAll}>See all</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{recentMatches.map((match) => (
|
||||||
|
<View key={match.id} style={styles.matchItem}>
|
||||||
|
<View style={styles.matchIcon}>
|
||||||
|
<Ionicons name="heart" size={20} color="#e94560" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.matchText} numberOfLines={1}>
|
||||||
|
{(match as any).question?.text || "A new match!"}
|
||||||
|
</Text>
|
||||||
|
{match.is_new && <View style={styles.newBadge} />}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#1a1a2e",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingTop: 60,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingBottom: 24,
|
||||||
|
},
|
||||||
|
greeting: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
partnerText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#888",
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
statsContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
statCard: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#0f3460",
|
||||||
|
},
|
||||||
|
statNumber: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#888",
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
ctaCard: {
|
||||||
|
margin: 24,
|
||||||
|
backgroundColor: "#e94560",
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
ctaContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
ctaTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
ctaSubtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
seeAll: {
|
||||||
|
color: "#e94560",
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
matchItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#0f3460",
|
||||||
|
},
|
||||||
|
matchIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: "rgba(233, 69, 96, 0.2)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
matchText: {
|
||||||
|
flex: 1,
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
newBadge: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: "#e94560",
|
||||||
|
},
|
||||||
|
});
|
||||||
Executable
+192
@@ -0,0 +1,192 @@
|
|||||||
|
import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity } from "react-native";
|
||||||
|
import { useMatchStore, useAuthStore } from "../../src/store";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
|
||||||
|
export default function MatchesScreen() {
|
||||||
|
const { matches, fetchMatches, markAllAsSeen } = useMatchStore();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMatches();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Mark all matches as seen when matches are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (matches.length > 0) {
|
||||||
|
markAllAsSeen();
|
||||||
|
}
|
||||||
|
}, [matches.length]);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: any }) => {
|
||||||
|
const userResponse = item.responses?.find((r: any) => r.user_id === user?.id);
|
||||||
|
const partnerResponse = item.responses?.find((r: any) => r.user_id !== user?.id);
|
||||||
|
|
||||||
|
let userText = item.question.text;
|
||||||
|
let partnerText = item.question.partner_text;
|
||||||
|
|
||||||
|
if (item.question.partner_text && userResponse && partnerResponse) {
|
||||||
|
const userTime = new Date(userResponse.created_at).getTime();
|
||||||
|
const partnerTime = new Date(partnerResponse.created_at).getTime();
|
||||||
|
|
||||||
|
// If user answered after partner, they saw the partner_text
|
||||||
|
if (userTime > partnerTime) {
|
||||||
|
userText = item.question.partner_text;
|
||||||
|
partnerText = item.question.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.matchCard}
|
||||||
|
onPress={() => router.push(`/chat/${item.id}`)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<Ionicons name="sparkles" size={24} color="#e94560" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.questionText}>{userText}</Text>
|
||||||
|
{item.question.partner_text && (
|
||||||
|
<Text style={styles.partnerText}>Partner: {partnerText}</Text>
|
||||||
|
)}
|
||||||
|
<View style={styles.tagContainer}>
|
||||||
|
<View style={styles.tag}>
|
||||||
|
<Text style={styles.tagText}>
|
||||||
|
{item.match_type === "yes_yes" ? "Review: YES + YES" : "Review: YES + MAYBE"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.date}>
|
||||||
|
{new Date(item.created_at).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator color="#e94560" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Your Matches</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={matches}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={false} onRefresh={fetchMatches} tintColor="#e94560" />
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyText}>No matches yet. Keep swiping!</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#1a1a2e",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingTop: 60,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingBottom: 24,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#16213e",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
matchCard: {
|
||||||
|
flexDirection: "row",
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#0f3460",
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: "rgba(233, 69, 96, 0.1)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
questionText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 8,
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
tagContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
backgroundColor: "rgba(233, 69, 96, 0.2)",
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
tagText: {
|
||||||
|
color: "#e94560",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
padding: 40,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
partnerText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#aaa",
|
||||||
|
fontStyle: "italic",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
Executable
+143
@@ -0,0 +1,143 @@
|
|||||||
|
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, Switch } from "react-native";
|
||||||
|
import { usePacksStore } from "../../src/store";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export default function PacksScreen() {
|
||||||
|
const { packs, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPacks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: any }) => (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.cardContent}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={() => router.push(`/pack/${item.id}`)}
|
||||||
|
>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<Text style={styles.emoji}>{item.icon || "📦"}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<Text style={styles.name}>{item.name}</Text>
|
||||||
|
{item.is_premium && (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>PRO</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.description} numberOfLines={2}>
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Switch
|
||||||
|
value={enabledPackIds.includes(item.id)}
|
||||||
|
onValueChange={() => togglePack(item.id)}
|
||||||
|
trackColor={{ false: "#767577", true: "#e94560" }}
|
||||||
|
thumbColor={"#fff"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Question Packs</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={packs}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
refreshing={isLoading}
|
||||||
|
onRefresh={fetchPacks}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#1a1a2e",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingTop: 60,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingBottom: 24,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#16213e",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#0f3460",
|
||||||
|
},
|
||||||
|
cardContent: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
emoji: {
|
||||||
|
fontSize: 24,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#fff",
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
backgroundColor: "#e94560",
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#888",
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
Executable
+337
@@ -0,0 +1,337 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
Share,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
} from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import { useAuthStore } from "../../src/store";
|
||||||
|
import { supabase } from "../../src/lib/supabase";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
|
||||||
|
export default function PairingScreen() {
|
||||||
|
const { user, fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore();
|
||||||
|
const [inviteCode, setInviteCode] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Redirect if already paired
|
||||||
|
useEffect(() => {
|
||||||
|
if (couple && partner) {
|
||||||
|
router.replace("/(app)/");
|
||||||
|
}
|
||||||
|
}, [couple, partner]);
|
||||||
|
|
||||||
|
const handleCreateCouple = async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.functions.invoke("manage-couple", {
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
if (data.error) throw new Error(data.error);
|
||||||
|
|
||||||
|
await fetchUser(); // Refresh user to get couple_id
|
||||||
|
await fetchCouple(); // Fetch couple data
|
||||||
|
} catch (error: any) {
|
||||||
|
Alert.alert("Error", error.message);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoinCouple = async () => {
|
||||||
|
if (inviteCode.length < 8) {
|
||||||
|
Alert.alert("Error", "Please enter a valid invite code");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
// Refresh session to ensure we have a valid token
|
||||||
|
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
|
||||||
|
if (sessionError || !session) {
|
||||||
|
throw new Error("Please log in again");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase.functions.invoke("manage-couple", {
|
||||||
|
method: "POST",
|
||||||
|
body: { invite_code: inviteCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
if (data.error) throw new Error(data.error);
|
||||||
|
|
||||||
|
await fetchUser(); // Refresh user to get couple_id
|
||||||
|
await fetchCouple(); // Fetch couple data
|
||||||
|
|
||||||
|
Alert.alert("Success", "You are now paired! 💕", [
|
||||||
|
{ text: "Let's Go", onPress: () => router.replace("/(app)/") }
|
||||||
|
]);
|
||||||
|
} catch (error: any) {
|
||||||
|
Alert.alert("Error", error.message);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
if (couple?.invite_code) {
|
||||||
|
await Clipboard.setStringAsync(couple.invite_code);
|
||||||
|
Alert.alert("Copied", "Invite code copied to clipboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareCode = async () => {
|
||||||
|
if (couple?.invite_code) {
|
||||||
|
try {
|
||||||
|
await Share.share({
|
||||||
|
message: `Join me on Sauci! Use my invite code to pair up: ${couple.invite_code}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isAuthLoading) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, styles.centerContent]}>
|
||||||
|
<ActivityIndicator size="large" color="#e94560" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user has a couple but no partner, they are waiting
|
||||||
|
if (couple && !partner) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<Ionicons name="heart" size={48} color="#e94560" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.title}>Partner Code</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Share this code with your partner to link your accounts
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.codeContainer} onPress={copyToClipboard}>
|
||||||
|
<Text style={styles.code}>{couple.invite_code.toUpperCase()}</Text>
|
||||||
|
<Ionicons name="copy-outline" size={24} color="#666" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.shareButton} onPress={shareCode}>
|
||||||
|
<Ionicons name="share-outline" size={24} color="#fff" style={{ marginRight: 8 }} />
|
||||||
|
<Text style={styles.buttonText}>Share Code</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Text style={styles.waitingText}>
|
||||||
|
Waiting for your partner to join...
|
||||||
|
</Text>
|
||||||
|
<ActivityIndicator size="small" color="#e94560" style={{ marginTop: 16 }} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.emoji}>🔗</Text>
|
||||||
|
<Text style={styles.title}>Pair Up</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Link with your partner to start matching
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Have a code?</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Enter invite code"
|
||||||
|
placeholderTextColor="#666"
|
||||||
|
value={inviteCode}
|
||||||
|
onChangeText={setInviteCode}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
maxLength={8}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, isSubmitting && styles.buttonDisabled]}
|
||||||
|
onPress={handleJoinCouple}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>
|
||||||
|
{isSubmitting ? "Joining..." : "Join Partner"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.divider}>
|
||||||
|
<View style={styles.dividerLine} />
|
||||||
|
<Text style={styles.dividerText}>or</Text>
|
||||||
|
<View style={styles.dividerLine} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.secondaryButton}
|
||||||
|
onPress={handleCreateCouple}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<Text style={styles.secondaryButtonText}>
|
||||||
|
{isSubmitting ? "Creating..." : "Create New Code"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#1a1a2e",
|
||||||
|
},
|
||||||
|
centerContent: {
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 24,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
emoji: {
|
||||||
|
fontSize: 64,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#888",
|
||||||
|
marginBottom: 48,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: "100%",
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 24,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#0f3460",
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: "#1a1a2e",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#0f3460",
|
||||||
|
textAlign: "center",
|
||||||
|
letterSpacing: 2,
|
||||||
|
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: "#e94560",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginVertical: 32,
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
dividerLine: {
|
||||||
|
flex: 1,
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: "#333",
|
||||||
|
},
|
||||||
|
dividerText: {
|
||||||
|
color: "#666",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
secondaryButtonText: {
|
||||||
|
color: "#e94560",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
backgroundColor: "rgba(233, 69, 96, 0.1)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
codeContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: 16,
|
||||||
|
marginTop: 32,
|
||||||
|
marginBottom: 24,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#e94560",
|
||||||
|
borderStyle: "dashed",
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
letterSpacing: 4,
|
||||||
|
marginRight: 16,
|
||||||
|
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
||||||
|
},
|
||||||
|
shareButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
backgroundColor: "#e94560",
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
waitingText: {
|
||||||
|
marginTop: 48,
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
Executable
+168
@@ -0,0 +1,168 @@
|
|||||||
|
import { View, Text, StyleSheet, TouchableOpacity, Alert, Image } from "react-native";
|
||||||
|
import { useAuthStore } from "../../src/store";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { supabase } from "../../src/lib/supabase";
|
||||||
|
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const { user, partner, signOut, fetchCouple } = useAuthStore();
|
||||||
|
|
||||||
|
const handleUnpair = async () => {
|
||||||
|
Alert.alert(
|
||||||
|
"Unpair Partner",
|
||||||
|
"Are you sure you want to unpair? This cannot be undone.",
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Unpair",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await supabase.functions.invoke("manage-couple", {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
await fetchCouple(); // Refresh state
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert("Error", "Failed to unpair");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Profile</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.profileCard}>
|
||||||
|
<View style={styles.avatar}>
|
||||||
|
<Text style={styles.avatarText}>
|
||||||
|
{user?.name?.[0]?.toUpperCase() || "U"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.name}>{user?.name || "User"}</Text>
|
||||||
|
<Text style={styles.email}>{user?.email}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionTitle}>Relationship</Text>
|
||||||
|
<View style={styles.section}>
|
||||||
|
{partner ? (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={styles.partnerInfo}>
|
||||||
|
<Ionicons name="heart" size={24} color="#e94560" />
|
||||||
|
<Text style={styles.rowText}>Paired with {partner.name || partner.email || 'your partner'}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={handleUnpair}>
|
||||||
|
<Ionicons name="trash-outline" size={20} color="#666" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.rowText}>Not paired yet</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionTitle}>Settings</Text>
|
||||||
|
<View style={styles.section}>
|
||||||
|
<TouchableOpacity style={styles.row} onPress={signOut}>
|
||||||
|
<Text style={[styles.rowText, { color: "#ff4444" }]}>Sign Out</Text>
|
||||||
|
<Ionicons name="log-out-outline" size={20} color="#ff4444" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.version}>Sauci v1.0.0</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#1a1a2e",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingTop: 60,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingBottom: 24,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#16213e",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
profileCard: {
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 40,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
backgroundColor: "#e94560",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#666",
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#666",
|
||||||
|
marginBottom: 8,
|
||||||
|
marginLeft: 4,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#1a1a2e",
|
||||||
|
},
|
||||||
|
partnerInfo: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
rowText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
});
|
||||||
Executable
+175
@@ -0,0 +1,175 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { View, StyleSheet, Text, ActivityIndicator } from "react-native";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { supabase } from "../../src/lib/supabase";
|
||||||
|
import SwipeCard from "../../src/components/SwipeCard";
|
||||||
|
|
||||||
|
export default function SwipeScreen() {
|
||||||
|
const { packId } = useLocalSearchParams();
|
||||||
|
const [questions, setQuestions] = useState<any[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchQuestions();
|
||||||
|
}, [packId]);
|
||||||
|
|
||||||
|
const fetchQuestions = async () => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.rpc("get_recommended_questions", {
|
||||||
|
target_pack_id: packId || null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Error fetching recommended questions:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smart Shuffle
|
||||||
|
// Priority:
|
||||||
|
// 1. Completion of Two-Part Match (Partner answered, is_two_part = true) -> +1.5
|
||||||
|
// 2. Partner Answered (Any) -> +0.7
|
||||||
|
// 3. New Two-Part (Get someone to start) -> +0.4
|
||||||
|
const sorted = (data || []).sort((a: any, b: any) => {
|
||||||
|
let scoreA = Math.random();
|
||||||
|
let scoreB = Math.random();
|
||||||
|
|
||||||
|
if (a.partner_answered && a.is_two_part) scoreA += 1.5;
|
||||||
|
else if (a.partner_answered) scoreA += 0.7;
|
||||||
|
else if (a.is_two_part) scoreA += 0.4;
|
||||||
|
|
||||||
|
if (b.partner_answered && b.is_two_part) scoreB += 1.5;
|
||||||
|
else if (b.partner_answered) scoreB += 0.7;
|
||||||
|
else if (b.is_two_part) scoreB += 0.4;
|
||||||
|
|
||||||
|
return scoreB - scoreA;
|
||||||
|
});
|
||||||
|
|
||||||
|
setQuestions(sorted);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwipe = async (direction: "left" | "right" | "up") => {
|
||||||
|
const question = questions[currentIndex];
|
||||||
|
const answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe";
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
setCurrentIndex(prev => prev + 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// DEBUG: Check if we have a valid session
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
console.log("DEBUG - Session exists:", !!session);
|
||||||
|
console.log("DEBUG - Access token exists:", !!session?.access_token);
|
||||||
|
console.log("DEBUG - Token preview:", session?.access_token?.substring(0, 50) + "...");
|
||||||
|
|
||||||
|
const result = await supabase.functions.invoke("submit-response", {
|
||||||
|
body: {
|
||||||
|
question_id: question.id,
|
||||||
|
answer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("DEBUG - Function result:", result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to submit response", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ActivityIndicator size="large" color="#e94560" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIndex >= questions.length) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Ionicons name="checkmark-circle-outline" size={80} color="#e94560" />
|
||||||
|
<Text style={styles.emptyTitle}>All caught up!</Text>
|
||||||
|
<Text style={styles.emptySubtitle}>
|
||||||
|
Check back later for more questions or try a different pack.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.progress}>
|
||||||
|
Question {currentIndex + 1} of {questions.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardContainer}>
|
||||||
|
{/* Render next card in background for smoothness */}
|
||||||
|
{questions[currentIndex + 1] && (
|
||||||
|
<View style={[styles.backgroundCard]} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active card */}
|
||||||
|
<SwipeCard
|
||||||
|
key={questions[currentIndex].id}
|
||||||
|
question={questions[currentIndex]}
|
||||||
|
onSwipe={handleSwipe}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#1a1a2e",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 60,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
cardContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 40,
|
||||||
|
},
|
||||||
|
backgroundCard: {
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%", // Match SwipeCard width logic
|
||||||
|
height: 500,
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderRadius: 24,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#0f3460",
|
||||||
|
transform: [{ scale: 0.95 }, { translateY: 10 }],
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
marginTop: 24,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptySubtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#888",
|
||||||
|
textAlign: "center",
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
},
|
||||||
|
});
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: "#1a1a2e" },
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+420
@@ -0,0 +1,420 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
} from "react-native";
|
||||||
|
import { Redirect } from "expo-router";
|
||||||
|
import * as Linking from "expo-linking";
|
||||||
|
import { supabase } from "../../src/lib/supabase";
|
||||||
|
import { useAuthStore } from "../../src/store";
|
||||||
|
|
||||||
|
|
||||||
|
type AuthMode = "magic-link" | "password";
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||||
|
const [authMode, setAuthMode] = useState<AuthMode>("magic-link");
|
||||||
|
const [isSignUp, setIsSignUp] = useState(false);
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
// Use declarative Redirect instead of imperative router.replace()
|
||||||
|
// This ensures navigation only happens after the Root Layout is mounted
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Redirect href="/" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMagicLink = async () => {
|
||||||
|
if (!email.trim()) {
|
||||||
|
Alert.alert("Error", "Please enter your email");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
const { error } = await supabase.auth.signInWithOtp({
|
||||||
|
email: email.trim(),
|
||||||
|
options: {
|
||||||
|
emailRedirectTo: Linking.createURL("/(auth)/login"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert("Error", error.message);
|
||||||
|
} else {
|
||||||
|
setIsMagicLinkSent(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordAuth = async () => {
|
||||||
|
if (!email.trim()) {
|
||||||
|
Alert.alert("Error", "Please enter your email");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
Alert.alert("Error", "Please enter your password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSignUp && password.length < 6) {
|
||||||
|
Alert.alert("Error", "Password must be at least 6 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
if (isSignUp) {
|
||||||
|
const { error } = await supabase.auth.signUp({
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert("Error", error.message);
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
"Check your email",
|
||||||
|
"We sent you a confirmation link to verify your email address.",
|
||||||
|
[{ text: "OK" }]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert("Error", error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleSignIn = async () => {
|
||||||
|
const { error } = await supabase.auth.signInWithOAuth({
|
||||||
|
provider: "google",
|
||||||
|
options: {
|
||||||
|
redirectTo: Linking.createURL("/(auth)/login"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert("Error", error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAppleSignIn = async () => {
|
||||||
|
const { error } = await supabase.auth.signInWithOAuth({
|
||||||
|
provider: "apple",
|
||||||
|
options: {
|
||||||
|
redirectTo: Linking.createURL("/(auth)/login"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert("Error", error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isMagicLinkSent) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.emoji}>✉️</Text>
|
||||||
|
<Text style={styles.title}>Check your email</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
We sent a magic link to {email}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.secondaryButton}
|
||||||
|
onPress={() => setIsMagicLinkSent(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.secondaryButtonText}>Try a different email</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.emoji}>💕</Text>
|
||||||
|
<Text style={styles.title}>Sauci</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Explore intimacy together
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Auth Mode Toggle */}
|
||||||
|
<View style={styles.modeToggleContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.modeToggle,
|
||||||
|
authMode === "magic-link" && styles.modeToggleActive,
|
||||||
|
]}
|
||||||
|
onPress={() => setAuthMode("magic-link")}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.modeToggleText,
|
||||||
|
authMode === "magic-link" && styles.modeToggleTextActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Magic Link
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.modeToggle,
|
||||||
|
authMode === "password" && styles.modeToggleActive,
|
||||||
|
]}
|
||||||
|
onPress={() => setAuthMode("password")}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.modeToggleText,
|
||||||
|
authMode === "password" && styles.modeToggleTextActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Enter your email"
|
||||||
|
placeholderTextColor="#666"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{authMode === "password" && (
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
placeholderTextColor="#666"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authMode === "magic-link" ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||||
|
onPress={handleMagicLink}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>
|
||||||
|
{isLoading ? "Sending..." : "Continue with Magic Link"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||||
|
onPress={handlePasswordAuth}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>
|
||||||
|
{isLoading
|
||||||
|
? isSignUp
|
||||||
|
? "Creating Account..."
|
||||||
|
: "Signing In..."
|
||||||
|
: isSignUp
|
||||||
|
? "Create Account"
|
||||||
|
: "Sign In"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.toggleSignUp}
|
||||||
|
onPress={() => setIsSignUp(!isSignUp)}
|
||||||
|
>
|
||||||
|
<Text style={styles.toggleSignUpText}>
|
||||||
|
{isSignUp
|
||||||
|
? "Already have an account? Sign In"
|
||||||
|
: "Don't have an account? Sign Up"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.divider}>
|
||||||
|
<View style={styles.dividerLine} />
|
||||||
|
<Text style={styles.dividerText}>or</Text>
|
||||||
|
<View style={styles.dividerLine} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.socialButton}
|
||||||
|
onPress={handleGoogleSignIn}
|
||||||
|
>
|
||||||
|
<Text style={styles.socialButtonText}>Continue with Google</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.socialButton}
|
||||||
|
onPress={handleAppleSignIn}
|
||||||
|
>
|
||||||
|
<Text style={styles.socialButtonText}>Continue with Apple</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#1a1a2e",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 80,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
emoji: {
|
||||||
|
fontSize: 64,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#888",
|
||||||
|
marginBottom: 48,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#0f3460",
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: "#e94560",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginVertical: 24,
|
||||||
|
},
|
||||||
|
dividerLine: {
|
||||||
|
flex: 1,
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: "#333",
|
||||||
|
},
|
||||||
|
dividerText: {
|
||||||
|
color: "#666",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
socialButton: {
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#0f3460",
|
||||||
|
},
|
||||||
|
socialButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
secondaryButtonText: {
|
||||||
|
color: "#e94560",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
modeToggleContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 4,
|
||||||
|
marginBottom: 24,
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
modeToggle: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
modeToggleActive: {
|
||||||
|
backgroundColor: "#e94560",
|
||||||
|
},
|
||||||
|
modeToggleText: {
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
modeToggleTextActive: {
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
toggleSignUp: {
|
||||||
|
marginTop: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
toggleSignUpText: {
|
||||||
|
color: "#e94560",
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
// Web polyfills must be imported FIRST before any other React Native imports
|
||||||
|
import "../src/polyfills/web";
|
||||||
|
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
|
||||||
|
// Suppress useLayoutEffect warning on server-side rendering
|
||||||
|
if (Platform.OS === "web" && typeof window === "undefined") {
|
||||||
|
React.useLayoutEffect = useEffect;
|
||||||
|
// Also silence the warning in case some libraries ignore the monkey-patch
|
||||||
|
const originalError = console.error;
|
||||||
|
console.error = (...args) => {
|
||||||
|
if (typeof args[0] === "string" && args[0].includes("useLayoutEffect does nothing on the server")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
originalError(...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
// GestureHandlerRootView is only needed on native platforms
|
||||||
|
// On web, we use a simple View to avoid findNodeHandle errors
|
||||||
|
const GestureHandlerRootView = Platform.OS === "web"
|
||||||
|
? View
|
||||||
|
: require("react-native-gesture-handler").GestureHandlerRootView;
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import * as Linking from "expo-linking";
|
||||||
|
import { useAuthStore } from "../src/store";
|
||||||
|
import { supabase } from "../src/lib/supabase";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const { fetchUser, setUser } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch user on mount
|
||||||
|
fetchUser();
|
||||||
|
|
||||||
|
// Handle initial URL if app was opened via deep link
|
||||||
|
const handleDeepLink = async (url: string | null) => {
|
||||||
|
if (url) {
|
||||||
|
// Supabase needs to handle the URL to extract session
|
||||||
|
// getSession() will automatically check window.location on web if detectSessionInUrl is true
|
||||||
|
// For native, we rely on onAuthStateChange or manual processing if needed
|
||||||
|
await supabase.auth.getSession();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get initial URL
|
||||||
|
Linking.getInitialURL().then(handleDeepLink);
|
||||||
|
|
||||||
|
// Listen for new deep links while app is open
|
||||||
|
const linkingSubscription = Linking.addEventListener("url", (event) => {
|
||||||
|
handleDeepLink(event.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for auth changes
|
||||||
|
const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange(
|
||||||
|
async (event, session) => {
|
||||||
|
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
||||||
|
fetchUser();
|
||||||
|
} else if (event === "SIGNED_OUT") {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
linkingSubscription.remove();
|
||||||
|
authSubscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: "#1a1a2e" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
import { Redirect } from "expo-router";
|
||||||
|
import { useAuthStore } from "../src/store";
|
||||||
|
import { View, ActivityIndicator, StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const { isAuthenticated, isLoading, user } = useAuthStore();
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ActivityIndicator size="large" color="#e94560" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect based on auth state
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Redirect href="/(auth)/login" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user has no couple, go to pairing
|
||||||
|
if (!user?.couple_id) {
|
||||||
|
return <Redirect href="/(app)/pairing" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise go to home
|
||||||
|
return <Redirect href="/(app)/" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#1a1a2e",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator } from "react-native";
|
||||||
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { supabase } from "../../src/lib/supabase";
|
||||||
|
import { usePacksStore } from "../../src/store";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Question } from "../../src/types";
|
||||||
|
|
||||||
|
export default function PackDetailsScreen() {
|
||||||
|
const { id } = useLocalSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const { packs, fetchPacks } = usePacksStore();
|
||||||
|
const [questions, setQuestions] = useState<Question[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const pack = packs.find((p) => p.id === id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (packs.length === 0) {
|
||||||
|
fetchPacks();
|
||||||
|
}
|
||||||
|
fetchPackDetails();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchPackDetails = async () => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("questions")
|
||||||
|
.select("*")
|
||||||
|
.eq("pack_id", id)
|
||||||
|
.order("created_at");
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setQuestions(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching questions:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: Question }) => (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.questionText}>{item.text}</Text>
|
||||||
|
{item.partner_text && (
|
||||||
|
<Text style={styles.partnerText}>Partner role: {item.partner_text}</Text>
|
||||||
|
)}
|
||||||
|
<View style={styles.intensityContainer}>
|
||||||
|
{[...Array(5)].map((_, index) => (
|
||||||
|
<Ionicons
|
||||||
|
key={index}
|
||||||
|
name="flame"
|
||||||
|
size={12}
|
||||||
|
color={index < item.intensity ? "#e94560" : "#333"}
|
||||||
|
style={{ marginRight: 2 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#e94560" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||||
|
<Ionicons name="arrow-back" size={24} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.title}>{pack?.name || "Pack Details"}</Text>
|
||||||
|
<View style={{ width: 24 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{pack && (
|
||||||
|
<View style={styles.packInfo}>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<Text style={styles.emoji}>{pack.icon || "📦"}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.description}>{pack.description}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>Questions ({questions.length})</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={questions}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#1a1a2e",
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#1a1a2e",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingTop: 60,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingBottom: 24,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#16213e",
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
packInfo: {
|
||||||
|
padding: 24,
|
||||||
|
alignItems: "center",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#16213e",
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
emoji: {
|
||||||
|
fontSize: 32,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#ccc",
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
padding: 24,
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#e94560",
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#0f3460",
|
||||||
|
},
|
||||||
|
questionText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 8,
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
intensityContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
},
|
||||||
|
partnerText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#aaa",
|
||||||
|
fontStyle: "italic",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 372 B |
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Find the project and workspace directories
|
||||||
|
const projectRoot = __dirname;
|
||||||
|
const workspaceRoot = path.resolve(projectRoot, '../..');
|
||||||
|
|
||||||
|
const config = getDefaultConfig(projectRoot);
|
||||||
|
|
||||||
|
// 1. Watch all files within the monorepo
|
||||||
|
// 1. Watch only relevant folders to improve performance on Windows
|
||||||
|
// watching the entire workspaceRoot is too slow
|
||||||
|
config.watchFolders = [
|
||||||
|
path.resolve(workspaceRoot, 'node_modules'),
|
||||||
|
path.resolve(workspaceRoot, 'packages/shared')
|
||||||
|
];
|
||||||
|
|
||||||
|
// 2. Let Metro know where to resolve packages and in which order
|
||||||
|
config.resolver.nodeModulesPaths = [
|
||||||
|
path.resolve(projectRoot, 'node_modules'),
|
||||||
|
path.resolve(workspaceRoot, 'node_modules'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 3. Force Metro to resolve (sub)dependencies from the `node_modules`
|
||||||
|
// of the workspace root, unless they are installed in the project root.
|
||||||
|
config.resolver.disableHierarchicalLookup = true;
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
Generated
+12353
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "@sauci/mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"lint": "expo lint",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^14.0.0",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||||
|
"@react-navigation/native": "^7.0.0",
|
||||||
|
"@react-navigation/native-stack": "^7.0.0",
|
||||||
|
"@supabase/supabase-js": "^2.39.0",
|
||||||
|
"@tanstack/react-query": "^5.17.0",
|
||||||
|
"base64-arraybuffer": "^1.0.2",
|
||||||
|
"expo": "~52.0.0",
|
||||||
|
"expo-clipboard": "~7.0.1",
|
||||||
|
"expo-constants": "~17.0.0",
|
||||||
|
"expo-file-system": "~18.0.12",
|
||||||
|
"expo-image-picker": "~16.0.6",
|
||||||
|
"expo-linking": "~7.0.0",
|
||||||
|
"expo-notifications": "~0.29.0",
|
||||||
|
"expo-router": "~4.0.0",
|
||||||
|
"expo-secure-store": "~14.0.0",
|
||||||
|
"expo-status-bar": "~2.0.0",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1",
|
||||||
|
"react-native": "0.76.5",
|
||||||
|
"react-native-gesture-handler": "~2.20.0",
|
||||||
|
"react-native-reanimated": "~3.16.0",
|
||||||
|
"react-native-safe-area-context": "4.12.0",
|
||||||
|
"react-native-screens": "~4.1.0",
|
||||||
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
|
"react-native-web": "^0.21.2",
|
||||||
|
"zustand": "^4.4.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.24.0",
|
||||||
|
"@types/react": "~18.3.12",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
+312
@@ -0,0 +1,312 @@
|
|||||||
|
import { View, Text, StyleSheet, Image, Dimensions, TouchableOpacity } from "react-native";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withSpring,
|
||||||
|
runOnJS,
|
||||||
|
interpolate,
|
||||||
|
Extrapolate,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
const SCREEN_WIDTH = Dimensions.get("window").width;
|
||||||
|
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.25;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
question: { id: string; text: string; intensity: number; partner_text?: string | null };
|
||||||
|
onSwipe: (direction: "left" | "right" | "up") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SwipeCard({ question, onSwipe }: Props) {
|
||||||
|
const translateX = useSharedValue(0);
|
||||||
|
const translateY = useSharedValue(0);
|
||||||
|
const rotation = useSharedValue(0);
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
|
||||||
|
const context = useSharedValue({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const gesture = Gesture.Pan()
|
||||||
|
.activeOffsetX([-15, 15])
|
||||||
|
.activeOffsetY([-15, 15])
|
||||||
|
.onStart(() => {
|
||||||
|
context.value = { x: translateX.value, y: translateY.value };
|
||||||
|
scale.value = withTiming(1.05);
|
||||||
|
})
|
||||||
|
.onUpdate((event) => {
|
||||||
|
translateX.value = event.translationX + context.value.x;
|
||||||
|
translateY.value = event.translationY + context.value.y;
|
||||||
|
rotation.value = interpolate(
|
||||||
|
translateX.value,
|
||||||
|
[-SCREEN_WIDTH / 2, SCREEN_WIDTH / 2],
|
||||||
|
[-15, 15],
|
||||||
|
Extrapolate.CLAMP
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
scale.value = withTiming(1);
|
||||||
|
|
||||||
|
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
|
||||||
|
// Horizontal swipe (YES/NO)
|
||||||
|
const direction = translateX.value > 0 ? "right" : "left";
|
||||||
|
translateX.value = withSpring(direction === "right" ? SCREEN_WIDTH * 1.5 : -SCREEN_WIDTH * 1.5);
|
||||||
|
runOnJS(onSwipe)(direction);
|
||||||
|
} else if (translateY.value < -SWIPE_THRESHOLD) {
|
||||||
|
// Vertical swipe up (MAYBE)
|
||||||
|
translateY.value = withSpring(-SCREEN_WIDTH * 1.5);
|
||||||
|
runOnJS(onSwipe)("up");
|
||||||
|
} else {
|
||||||
|
// Reset
|
||||||
|
translateX.value = withSpring(0);
|
||||||
|
translateY.value = withSpring(0);
|
||||||
|
rotation.value = withSpring(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [
|
||||||
|
{ translateX: translateX.value },
|
||||||
|
{ translateY: translateY.value },
|
||||||
|
{ rotate: `${rotation.value}deg` },
|
||||||
|
{ scale: scale.value },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const overlayStyle = (direction: "left" | "right" | "up", color: string) =>
|
||||||
|
useAnimatedStyle(() => {
|
||||||
|
let opacity = 0;
|
||||||
|
if (direction === "right") {
|
||||||
|
opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1]);
|
||||||
|
} else if (direction === "left") {
|
||||||
|
opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 1]);
|
||||||
|
} else {
|
||||||
|
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
opacity,
|
||||||
|
backgroundColor: color,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.cardWrapper}>
|
||||||
|
<GestureDetector gesture={gesture}>
|
||||||
|
<Animated.View style={[styles.card, animatedStyle]}>
|
||||||
|
{/* Overlays */}
|
||||||
|
<Animated.View style={[styles.overlay, overlayStyle("right", "rgba(76, 175, 80, 0.4)")]}>
|
||||||
|
<Text style={styles.overlayText}>YES</Text>
|
||||||
|
</Animated.View>
|
||||||
|
<Animated.View style={[styles.overlay, overlayStyle("left", "rgba(244, 67, 54, 0.4)")]}>
|
||||||
|
<Text style={styles.overlayText}>NO</Text>
|
||||||
|
</Animated.View>
|
||||||
|
<Animated.View style={[styles.overlay, overlayStyle("up", "rgba(255, 152, 0, 0.4)")]}>
|
||||||
|
<Text style={styles.overlayText}>MAYBE</Text>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.intensityContainer}>
|
||||||
|
{[...Array(question.intensity)].map((_, i) => (
|
||||||
|
<Ionicons key={i} name="flame" size={16} color="#e94560" />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.text, question.is_two_part ? styles.twoPartText : null]}>{question.text}</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.noButton]}
|
||||||
|
onPress={() => onSwipe("left")}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.buttonInnerGlow} />
|
||||||
|
<Ionicons name="thumbs-down" size={24} color="#fff" style={styles.buttonIcon} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.maybeButton]}
|
||||||
|
onPress={() => onSwipe("up")}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.buttonInnerGlow} />
|
||||||
|
<Ionicons name="help-circle" size={24} color="#fff" style={styles.buttonIcon} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.yesButton]}
|
||||||
|
onPress={() => onSwipe("right")}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.buttonInnerGlow} />
|
||||||
|
<Ionicons name="thumbs-up" size={24} color="#fff" style={styles.buttonIcon} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
cardWrapper: {
|
||||||
|
position: "absolute",
|
||||||
|
width: SCREEN_WIDTH - 48,
|
||||||
|
height: 500,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: "100%",
|
||||||
|
height: 500,
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderRadius: 24,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#0f3460",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 4.65,
|
||||||
|
elevation: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 32,
|
||||||
|
},
|
||||||
|
intensityContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: 24,
|
||||||
|
backgroundColor: "rgba(233, 69, 96, 0.1)",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 32,
|
||||||
|
},
|
||||||
|
twoPartText: {
|
||||||
|
fontSize: 20,
|
||||||
|
lineHeight: 28,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 16,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.2)",
|
||||||
|
borderBottomLeftRadius: 24,
|
||||||
|
borderBottomRightRadius: 24,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
overlayText: {
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: "900",
|
||||||
|
color: "#fff",
|
||||||
|
textShadowColor: "rgba(0,0,0,0.5)",
|
||||||
|
textShadowOffset: { width: 0, height: 2 },
|
||||||
|
textShadowRadius: 4,
|
||||||
|
transform: [{ rotate: "-15deg" }],
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: "#fff",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
partnerContainer: {
|
||||||
|
width: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: 1,
|
||||||
|
width: "80%",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
marginVertical: 24,
|
||||||
|
position: "relative",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
dividerText: {
|
||||||
|
color: "#666",
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "bold",
|
||||||
|
position: "absolute",
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
paddingVertical: 16,
|
||||||
|
gap: 20,
|
||||||
|
zIndex: 21,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
borderWidth: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
zIndex: 22,
|
||||||
|
},
|
||||||
|
noButton: {
|
||||||
|
backgroundColor: "#E74C3C",
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||||
|
},
|
||||||
|
maybeButton: {
|
||||||
|
backgroundColor: "#F39C12",
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||||
|
},
|
||||||
|
yesButton: {
|
||||||
|
backgroundColor: "#2ECC71",
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||||
|
},
|
||||||
|
buttonInnerGlow: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: "50%",
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||||
|
borderTopLeftRadius: 32,
|
||||||
|
borderTopRightRadius: 32,
|
||||||
|
},
|
||||||
|
buttonIcon: {
|
||||||
|
zIndex: 1,
|
||||||
|
textShadowColor: "rgba(0, 0, 0, 0.3)",
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* SwipeCard component with platform-specific implementations.
|
||||||
|
*
|
||||||
|
* - On native (iOS/Android): Uses react-native-gesture-handler with Reanimated
|
||||||
|
* for smooth, native-level gesture performance (SwipeCard.native.tsx)
|
||||||
|
*
|
||||||
|
* - On web: Uses React Native's built-in PanResponder which is compatible with
|
||||||
|
* react-native-web (SwipeCard.web.tsx)
|
||||||
|
*
|
||||||
|
* Metro bundler automatically picks the right file based on the platform:
|
||||||
|
* - .native.tsx for iOS/Android
|
||||||
|
* - .web.tsx for web
|
||||||
|
*
|
||||||
|
* This file is a fallback that re-exports the web version for any other platform
|
||||||
|
* that might not have a specific implementation.
|
||||||
|
*/
|
||||||
|
export { default } from "./SwipeCard.web";
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { View, Text, StyleSheet, Dimensions, Animated, PanResponder, TouchableOpacity } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
const SCREEN_WIDTH = Dimensions.get("window").width;
|
||||||
|
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.25;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
question: { id: string; text: string; intensity: number; partner_text?: string | null };
|
||||||
|
onSwipe: (direction: "left" | "right" | "up") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web-compatible SwipeCard using React Native's built-in PanResponder
|
||||||
|
* instead of react-native-gesture-handler which doesn't work on web.
|
||||||
|
*/
|
||||||
|
export default function SwipeCard({ question, onSwipe }: Props) {
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const translateX = useRef(new Animated.Value(0)).current;
|
||||||
|
const translateY = useRef(new Animated.Value(0)).current;
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const rotation = translateX.interpolate({
|
||||||
|
inputRange: [-SCREEN_WIDTH / 2, SCREEN_WIDTH / 2],
|
||||||
|
outputRange: ["-15deg", "15deg"],
|
||||||
|
extrapolate: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightOverlayOpacity = translateX.interpolate({
|
||||||
|
inputRange: [0, SWIPE_THRESHOLD],
|
||||||
|
outputRange: [0, 1],
|
||||||
|
extrapolate: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const leftOverlayOpacity = translateX.interpolate({
|
||||||
|
inputRange: [-SWIPE_THRESHOLD, 0],
|
||||||
|
outputRange: [1, 0],
|
||||||
|
extrapolate: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const upOverlayOpacity = translateY.interpolate({
|
||||||
|
inputRange: [-SWIPE_THRESHOLD, 0],
|
||||||
|
outputRange: [1, 0],
|
||||||
|
extrapolate: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const panResponder = useRef(
|
||||||
|
PanResponder.create({
|
||||||
|
onStartShouldSetPanResponder: (_, gestureState) => {
|
||||||
|
// Don't activate if touch is in footer area (bottom 100px)
|
||||||
|
const CARD_HEIGHT = 500;
|
||||||
|
const FOOTER_START_Y = CARD_HEIGHT - 100;
|
||||||
|
return gestureState.y0 < FOOTER_START_Y;
|
||||||
|
},
|
||||||
|
onMoveShouldSetPanResponder: (_, gestureState) => {
|
||||||
|
// Don't activate if touch is in footer area
|
||||||
|
const CARD_HEIGHT = 500;
|
||||||
|
const FOOTER_START_Y = CARD_HEIGHT - 100;
|
||||||
|
return gestureState.y0 < FOOTER_START_Y && (Math.abs(gestureState.dx) > 5 || Math.abs(gestureState.dy) > 5);
|
||||||
|
},
|
||||||
|
onPanResponderGrant: () => {
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: 1.05,
|
||||||
|
duration: 100,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
},
|
||||||
|
onPanResponderMove: (_, gestureState) => {
|
||||||
|
translateX.setValue(gestureState.dx);
|
||||||
|
translateY.setValue(gestureState.dy);
|
||||||
|
},
|
||||||
|
onPanResponderRelease: (_, gestureState) => {
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 100,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
if (Math.abs(gestureState.dx) > SWIPE_THRESHOLD) {
|
||||||
|
// Horizontal swipe
|
||||||
|
const direction = gestureState.dx > 0 ? "right" : "left";
|
||||||
|
const targetX = direction === "right" ? SCREEN_WIDTH * 1.5 : -SCREEN_WIDTH * 1.5;
|
||||||
|
|
||||||
|
Animated.spring(translateX, {
|
||||||
|
toValue: targetX,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => {
|
||||||
|
setDismissed(true);
|
||||||
|
onSwipe(direction);
|
||||||
|
});
|
||||||
|
} else if (gestureState.dy < -SWIPE_THRESHOLD) {
|
||||||
|
// Vertical swipe up
|
||||||
|
Animated.spring(translateY, {
|
||||||
|
toValue: -SCREEN_WIDTH * 1.5,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => {
|
||||||
|
setDismissed(true);
|
||||||
|
onSwipe("up");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Reset position
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.spring(translateX, {
|
||||||
|
toValue: 0,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.spring(translateY, {
|
||||||
|
toValue: 0,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).current;
|
||||||
|
|
||||||
|
if (dismissed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{
|
||||||
|
transform: [
|
||||||
|
{ translateX },
|
||||||
|
{ translateY },
|
||||||
|
{ rotate: rotation },
|
||||||
|
{ scale },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
{...panResponder.panHandlers}
|
||||||
|
>
|
||||||
|
{/* Overlays */}
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.overlay,
|
||||||
|
{ opacity: rightOverlayOpacity, backgroundColor: "rgba(76, 175, 80, 0.4)" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.overlayText}>YES</Text>
|
||||||
|
</Animated.View>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.overlay,
|
||||||
|
{ opacity: leftOverlayOpacity, backgroundColor: "rgba(244, 67, 54, 0.4)" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.overlayText}>NO</Text>
|
||||||
|
</Animated.View>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.overlay,
|
||||||
|
{ opacity: upOverlayOpacity, backgroundColor: "rgba(255, 152, 0, 0.4)" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.overlayText}>MAYBE</Text>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.intensityContainer}>
|
||||||
|
{[...Array(question.intensity)].map((_, i) => (
|
||||||
|
<Ionicons key={i} name="flame" size={16} color="#e94560" />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.text, question.is_two_part ? styles.twoPartText : null]}>{question.text}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.actionButton,
|
||||||
|
styles.noButton,
|
||||||
|
hoveredButton === "no" && styles.buttonHovered,
|
||||||
|
]}
|
||||||
|
onPress={() => onSwipe("left")}
|
||||||
|
onMouseEnter={() => setHoveredButton("no")}
|
||||||
|
onMouseLeave={() => setHoveredButton(null)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.buttonInnerGlow} />
|
||||||
|
<Ionicons name="thumbs-down" size={24} color="#fff" style={styles.buttonIcon} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.actionButton,
|
||||||
|
styles.maybeButton,
|
||||||
|
hoveredButton === "maybe" && styles.buttonHovered,
|
||||||
|
]}
|
||||||
|
onPress={() => onSwipe("up")}
|
||||||
|
onMouseEnter={() => setHoveredButton("maybe")}
|
||||||
|
onMouseLeave={() => setHoveredButton(null)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.buttonInnerGlow} />
|
||||||
|
<Ionicons name="help-circle" size={24} color="#fff" style={styles.buttonIcon} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.actionButton,
|
||||||
|
styles.yesButton,
|
||||||
|
hoveredButton === "yes" && styles.buttonHovered,
|
||||||
|
]}
|
||||||
|
onPress={() => onSwipe("right")}
|
||||||
|
onMouseEnter={() => setHoveredButton("yes")}
|
||||||
|
onMouseLeave={() => setHoveredButton(null)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.buttonInnerGlow} />
|
||||||
|
<Ionicons name="thumbs-up" size={24} color="#fff" style={styles.buttonIcon} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
position: "absolute",
|
||||||
|
width: SCREEN_WIDTH - 48,
|
||||||
|
height: 500,
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
borderRadius: 24,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#0f3460",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 4.65,
|
||||||
|
elevation: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
cursor: "grab",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 32,
|
||||||
|
},
|
||||||
|
intensityContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: 24,
|
||||||
|
backgroundColor: "rgba(233, 69, 96, 0.1)",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 32,
|
||||||
|
},
|
||||||
|
twoPartText: {
|
||||||
|
fontSize: 20,
|
||||||
|
lineHeight: 28,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 16,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.2)",
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
color: "#666",
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
overlayText: {
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: "900",
|
||||||
|
color: "#fff",
|
||||||
|
textShadowColor: "rgba(0,0,0,0.5)",
|
||||||
|
textShadowOffset: { width: 0, height: 2 },
|
||||||
|
textShadowRadius: 4,
|
||||||
|
transform: [{ rotate: "-15deg" }],
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: "#fff",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
partnerContainer: {
|
||||||
|
width: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: 1,
|
||||||
|
width: "80%",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
marginVertical: 24,
|
||||||
|
position: "relative",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
dividerText: {
|
||||||
|
color: "#666",
|
||||||
|
backgroundColor: "#16213e",
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "bold",
|
||||||
|
position: "absolute",
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
paddingVertical: 16,
|
||||||
|
gap: 20,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
borderWidth: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
transition: "transform 0.2s ease, box-shadow 0.2s ease",
|
||||||
|
},
|
||||||
|
noButton: {
|
||||||
|
backgroundColor: "#E74C3C",
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||||
|
},
|
||||||
|
maybeButton: {
|
||||||
|
backgroundColor: "#F39C12",
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||||
|
},
|
||||||
|
yesButton: {
|
||||||
|
backgroundColor: "#2ECC71",
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||||
|
},
|
||||||
|
buttonInnerGlow: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: "50%",
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||||
|
borderTopLeftRadius: 32,
|
||||||
|
borderTopRightRadius: 32,
|
||||||
|
},
|
||||||
|
buttonIcon: {
|
||||||
|
zIndex: 1,
|
||||||
|
textShadowColor: "rgba(0, 0, 0, 0.3)",
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: 2,
|
||||||
|
},
|
||||||
|
buttonHovered: {
|
||||||
|
transform: [{ scale: 1.1 }],
|
||||||
|
shadowOpacity: 0.6,
|
||||||
|
shadowRadius: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
import "react-native-url-polyfill/auto";
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
// In-memory storage fallback for SSR/Node.js environments
|
||||||
|
const memoryStorage: Record<string, string> = {};
|
||||||
|
|
||||||
|
// SecureStore adapter for Supabase auth
|
||||||
|
const ExpoSecureStoreAdapter = {
|
||||||
|
getItem: (key: string) => {
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
// Check if we're in a browser environment with localStorage
|
||||||
|
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
||||||
|
return window.localStorage.getItem(key);
|
||||||
|
}
|
||||||
|
// Fallback for SSR/Node.js
|
||||||
|
return memoryStorage[key] ?? null;
|
||||||
|
}
|
||||||
|
return SecureStore.getItemAsync(key);
|
||||||
|
},
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
||||||
|
window.localStorage.setItem(key, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fallback for SSR/Node.js
|
||||||
|
memoryStorage[key] = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return SecureStore.setItemAsync(key, value);
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fallback for SSR/Node.js
|
||||||
|
delete memoryStorage[key];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return SecureStore.deleteItemAsync(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!;
|
||||||
|
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
|
||||||
|
// DEBUG: Log which key is being used
|
||||||
|
console.log("DEBUG - Anon key preview:", supabaseAnonKey?.substring(0, 30) + "...");
|
||||||
|
|
||||||
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||||
|
auth: {
|
||||||
|
storage: ExpoSecureStoreAdapter,
|
||||||
|
autoRefreshToken: true,
|
||||||
|
persistSession: true,
|
||||||
|
detectSessionInUrl: Platform.OS === "web",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Web platform polyfills
|
||||||
|
* This file must be imported BEFORE any other React Native imports
|
||||||
|
* to ensure the polyfills are applied before libraries like react-native-gesture-handler load.
|
||||||
|
*
|
||||||
|
* IMPORTANT: Do NOT use ES6 imports from react-native in this file!
|
||||||
|
* Using imports would cause react-native to be loaded before the polyfill runs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// We need to detect web platform without importing react-native
|
||||||
|
const isWeb = typeof document !== "undefined";
|
||||||
|
|
||||||
|
if (isWeb) {
|
||||||
|
// Polyfill findNodeHandle for web platform
|
||||||
|
// react-native-gesture-handler uses this internally but react-native-web throws an error
|
||||||
|
// We patch it to return null instead, which is safer than throwing
|
||||||
|
try {
|
||||||
|
// Get react-native module and override findNodeHandle
|
||||||
|
const RN = require("react-native");
|
||||||
|
|
||||||
|
// Create a no-op function that returns null
|
||||||
|
const webFindNodeHandle = () => null;
|
||||||
|
|
||||||
|
// Override the module's export
|
||||||
|
Object.defineProperty(RN, "findNodeHandle", {
|
||||||
|
value: webFindNodeHandle,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to polyfill findNodeHandle:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+245
@@ -0,0 +1,245 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { supabase } from "../lib/supabase";
|
||||||
|
import type { Profile, Couple, Match, QuestionPack } from "@/types";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: Profile | null;
|
||||||
|
couple: Couple | null;
|
||||||
|
partner: Profile | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchUser: () => Promise<void>;
|
||||||
|
fetchCouple: () => Promise<void>;
|
||||||
|
signOut: () => Promise<void>;
|
||||||
|
setUser: (user: Profile | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
|
user: null,
|
||||||
|
couple: null,
|
||||||
|
partner: null,
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
fetchUser: async () => {
|
||||||
|
try {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
set({ user: null, isAuthenticated: false, isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", session.user.id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
set({
|
||||||
|
user: profile,
|
||||||
|
isAuthenticated: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If user has a couple, fetch couple data
|
||||||
|
if (profile?.couple_id) {
|
||||||
|
await get().fetchCouple();
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching user:", error);
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchCouple: async () => {
|
||||||
|
const user = get().user;
|
||||||
|
if (!user?.couple_id) return;
|
||||||
|
|
||||||
|
const { data: couple } = await supabase
|
||||||
|
.from("couples")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", user.couple_id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
// Fetch partner
|
||||||
|
const { data: partner } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("*")
|
||||||
|
.eq("couple_id", user.couple_id)
|
||||||
|
.neq("id", user.id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
set({ couple, partner });
|
||||||
|
},
|
||||||
|
|
||||||
|
signOut: async () => {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
couple: null,
|
||||||
|
partner: null,
|
||||||
|
isAuthenticated: false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Match store for managing matches
|
||||||
|
interface MatchState {
|
||||||
|
matches: Match[];
|
||||||
|
newMatchesCount: number;
|
||||||
|
fetchMatches: () => Promise<void>;
|
||||||
|
markAsSeen: (matchId: string) => Promise<void>;
|
||||||
|
markAllAsSeen: () => Promise<void>;
|
||||||
|
addMatch: (match: Match) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMatchStore = create<MatchState>((set, get) => ({
|
||||||
|
matches: [],
|
||||||
|
newMatchesCount: 0,
|
||||||
|
|
||||||
|
fetchMatches: async () => {
|
||||||
|
const { data: matches } = await supabase
|
||||||
|
.from("matches")
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
question:questions(*)
|
||||||
|
`)
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
|
||||||
|
if (!matches) return;
|
||||||
|
|
||||||
|
// Fetch all responses for these questions to determine who answered first
|
||||||
|
const questionIds = matches.map(m => m.question_id);
|
||||||
|
const { data: responses } = await supabase
|
||||||
|
.from("responses")
|
||||||
|
.select("*")
|
||||||
|
.in("question_id", questionIds);
|
||||||
|
|
||||||
|
const data = matches.map(match => ({
|
||||||
|
...match,
|
||||||
|
responses: responses?.filter(r => r.question_id === match.question_id) || []
|
||||||
|
}));
|
||||||
|
|
||||||
|
const newCount = data.filter((m) => m.is_new).length;
|
||||||
|
set({ matches: data, newMatchesCount: newCount });
|
||||||
|
},
|
||||||
|
|
||||||
|
markAsSeen: async (matchId) => {
|
||||||
|
await supabase
|
||||||
|
.from("matches")
|
||||||
|
.update({ is_new: false })
|
||||||
|
.eq("id", matchId);
|
||||||
|
|
||||||
|
const matches = get().matches.map((m) =>
|
||||||
|
m.id === matchId ? { ...m, is_new: false } : m
|
||||||
|
);
|
||||||
|
const newCount = matches.filter((m) => m.is_new).length;
|
||||||
|
set({ matches, newMatchesCount: newCount });
|
||||||
|
},
|
||||||
|
|
||||||
|
markAllAsSeen: async () => {
|
||||||
|
const newMatches = get().matches.filter((m) => m.is_new);
|
||||||
|
if (newMatches.length === 0) return;
|
||||||
|
|
||||||
|
const newMatchIds = newMatches.map((m) => m.id);
|
||||||
|
await supabase
|
||||||
|
.from("matches")
|
||||||
|
.update({ is_new: false })
|
||||||
|
.in("id", newMatchIds);
|
||||||
|
|
||||||
|
const matches = get().matches.map((m) =>
|
||||||
|
m.is_new ? { ...m, is_new: false } : m
|
||||||
|
);
|
||||||
|
set({ matches, newMatchesCount: 0 });
|
||||||
|
},
|
||||||
|
|
||||||
|
addMatch: (match) => {
|
||||||
|
set((state) => ({
|
||||||
|
matches: [match, ...state.matches],
|
||||||
|
newMatchesCount: state.newMatchesCount + 1,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Packs store
|
||||||
|
interface PacksState {
|
||||||
|
packs: QuestionPack[];
|
||||||
|
enabledPackIds: string[];
|
||||||
|
isLoading: boolean;
|
||||||
|
fetchPacks: () => Promise<void>;
|
||||||
|
fetchEnabledPacks: () => Promise<void>;
|
||||||
|
togglePack: (packId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePacksStore = create<PacksState>((set, get) => ({
|
||||||
|
packs: [],
|
||||||
|
enabledPackIds: [],
|
||||||
|
isLoading: false,
|
||||||
|
|
||||||
|
fetchPacks: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
|
||||||
|
const { data: packs } = await supabase
|
||||||
|
.from("question_packs")
|
||||||
|
.select("*")
|
||||||
|
.eq("is_public", true)
|
||||||
|
.order("sort_order");
|
||||||
|
|
||||||
|
// Also fetch enabled packs if logged in
|
||||||
|
await get().fetchEnabledPacks();
|
||||||
|
|
||||||
|
set({ packs: packs ?? [], isLoading: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchEnabledPacks: async () => {
|
||||||
|
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||||
|
if (!coupleId) {
|
||||||
|
set({ enabledPackIds: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: couplePacks } = await supabase
|
||||||
|
.from("couple_packs")
|
||||||
|
.select("pack_id")
|
||||||
|
.eq("couple_id", coupleId)
|
||||||
|
.eq("enabled", true);
|
||||||
|
|
||||||
|
const ids = couplePacks?.map(cp => cp.pack_id) || [];
|
||||||
|
set({ enabledPackIds: ids });
|
||||||
|
},
|
||||||
|
|
||||||
|
togglePack: async (packId: string) => {
|
||||||
|
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||||
|
if (!coupleId) return;
|
||||||
|
|
||||||
|
const isEnabled = get().enabledPackIds.includes(packId);
|
||||||
|
const newValue = !isEnabled;
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
const newIds = newValue
|
||||||
|
? [...get().enabledPackIds, packId]
|
||||||
|
: get().enabledPackIds.filter(id => id !== packId);
|
||||||
|
|
||||||
|
set({ enabledPackIds: newIds });
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("couple_packs")
|
||||||
|
.upsert({
|
||||||
|
couple_id: coupleId,
|
||||||
|
pack_id: packId,
|
||||||
|
enabled: newValue
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Error toggling pack:", error);
|
||||||
|
// Revert by refetching
|
||||||
|
get().fetchEnabledPacks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
Executable
+80
@@ -0,0 +1,80 @@
|
|||||||
|
// User profile (extends Supabase auth.users)
|
||||||
|
export interface Profile {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
push_token: string | null;
|
||||||
|
is_premium: boolean;
|
||||||
|
couple_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Couple pairing
|
||||||
|
export interface Couple {
|
||||||
|
id: string;
|
||||||
|
invite_code: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Question pack
|
||||||
|
export interface QuestionPack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
is_premium: boolean;
|
||||||
|
is_public: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual question
|
||||||
|
export interface Question {
|
||||||
|
id: string;
|
||||||
|
pack_id: string;
|
||||||
|
text: string;
|
||||||
|
partner_text?: string | null;
|
||||||
|
intensity: 1 | 2 | 3 | 4 | 5;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User's answer type
|
||||||
|
export type AnswerType = 'yes' | 'no' | 'maybe';
|
||||||
|
|
||||||
|
// Match type when both partners agree
|
||||||
|
export type MatchType = 'yes_yes' | 'yes_maybe' | 'maybe_maybe';
|
||||||
|
|
||||||
|
// User response to a question
|
||||||
|
export interface Response {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
question_id: string;
|
||||||
|
couple_id: string;
|
||||||
|
answer: AnswerType;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match between partners
|
||||||
|
export interface Match {
|
||||||
|
id: string;
|
||||||
|
couple_id: string;
|
||||||
|
question_id: string;
|
||||||
|
match_type: MatchType;
|
||||||
|
is_new: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended match with question details
|
||||||
|
export interface MatchWithQuestion extends Match {
|
||||||
|
question: Question;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pack with progress info
|
||||||
|
export interface PackProgress {
|
||||||
|
pack: QuestionPack;
|
||||||
|
total_questions: number;
|
||||||
|
answered_questions: number;
|
||||||
|
matches_count: number;
|
||||||
|
}
|
||||||
Executable
+445
@@ -0,0 +1,445 @@
|
|||||||
|
export type Json =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| { [key: string]: Json | undefined }
|
||||||
|
| Json[]
|
||||||
|
|
||||||
|
export type Database = {
|
||||||
|
// Allows to automatically instantiate createClient with right options
|
||||||
|
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
||||||
|
__InternalSupabase: {
|
||||||
|
PostgrestVersion: "14.1"
|
||||||
|
}
|
||||||
|
public: {
|
||||||
|
Tables: {
|
||||||
|
couple_packs: {
|
||||||
|
Row: {
|
||||||
|
couple_id: string
|
||||||
|
created_at: string | null
|
||||||
|
enabled: boolean | null
|
||||||
|
pack_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
couple_id: string
|
||||||
|
created_at?: string | null
|
||||||
|
enabled?: boolean | null
|
||||||
|
pack_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
couple_id?: string
|
||||||
|
created_at?: string | null
|
||||||
|
enabled?: boolean | null
|
||||||
|
pack_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "couple_packs_couple_id_fkey"
|
||||||
|
columns: ["couple_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "couples"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "couple_packs_pack_id_fkey"
|
||||||
|
columns: ["pack_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "question_packs"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
couples: {
|
||||||
|
Row: {
|
||||||
|
created_at: string | null
|
||||||
|
id: string
|
||||||
|
invite_code: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
invite_code?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
invite_code?: string | null
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
matches: {
|
||||||
|
Row: {
|
||||||
|
couple_id: string
|
||||||
|
created_at: string | null
|
||||||
|
id: string
|
||||||
|
is_new: boolean | null
|
||||||
|
match_type: Database["public"]["Enums"]["match_type"]
|
||||||
|
question_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
couple_id: string
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
is_new?: boolean | null
|
||||||
|
match_type: Database["public"]["Enums"]["match_type"]
|
||||||
|
question_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
couple_id?: string
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
is_new?: boolean | null
|
||||||
|
match_type?: Database["public"]["Enums"]["match_type"]
|
||||||
|
question_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "matches_couple_id_fkey"
|
||||||
|
columns: ["couple_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "couples"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "matches_question_id_fkey"
|
||||||
|
columns: ["question_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "questions"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
messages: {
|
||||||
|
Row: {
|
||||||
|
content: string | null
|
||||||
|
created_at: string | null
|
||||||
|
id: string
|
||||||
|
match_id: string
|
||||||
|
media_path: string | null
|
||||||
|
media_viewed_at: string | null
|
||||||
|
read_at: string | null
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
content?: string | null
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
match_id: string
|
||||||
|
media_path?: string | null
|
||||||
|
media_viewed_at?: string | null
|
||||||
|
read_at?: string | null
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
content?: string | null
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
match_id?: string
|
||||||
|
media_path?: string | null
|
||||||
|
media_viewed_at?: string | null
|
||||||
|
read_at?: string | null
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "messages_match_id_fkey"
|
||||||
|
columns: ["match_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "matches"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
profiles: {
|
||||||
|
Row: {
|
||||||
|
avatar_url: string | null
|
||||||
|
couple_id: string | null
|
||||||
|
created_at: string | null
|
||||||
|
email: string | null
|
||||||
|
id: string
|
||||||
|
is_premium: boolean | null
|
||||||
|
name: string | null
|
||||||
|
push_token: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
avatar_url?: string | null
|
||||||
|
couple_id?: string | null
|
||||||
|
created_at?: string | null
|
||||||
|
email?: string | null
|
||||||
|
id: string
|
||||||
|
is_premium?: boolean | null
|
||||||
|
name?: string | null
|
||||||
|
push_token?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
avatar_url?: string | null
|
||||||
|
couple_id?: string | null
|
||||||
|
created_at?: string | null
|
||||||
|
email?: string | null
|
||||||
|
id?: string
|
||||||
|
is_premium?: boolean | null
|
||||||
|
name?: string | null
|
||||||
|
push_token?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "profiles_couple_id_fkey"
|
||||||
|
columns: ["couple_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "couples"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
question_packs: {
|
||||||
|
Row: {
|
||||||
|
created_at: string | null
|
||||||
|
description: string | null
|
||||||
|
display_order: number
|
||||||
|
id: string
|
||||||
|
image_url: string | null
|
||||||
|
is_explicit: boolean | null
|
||||||
|
is_pro: boolean | null
|
||||||
|
is_public: boolean | null
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string | null
|
||||||
|
description?: string | null
|
||||||
|
display_order?: number
|
||||||
|
id?: string
|
||||||
|
image_url?: string | null
|
||||||
|
is_explicit?: boolean | null
|
||||||
|
is_pro?: boolean | null
|
||||||
|
is_public?: boolean | null
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string | null
|
||||||
|
description?: string | null
|
||||||
|
display_order?: number
|
||||||
|
id?: string
|
||||||
|
image_url?: string | null
|
||||||
|
is_explicit?: boolean | null
|
||||||
|
is_pro?: boolean | null
|
||||||
|
is_public?: boolean | null
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
questions: {
|
||||||
|
Row: {
|
||||||
|
created_at: string | null
|
||||||
|
id: string
|
||||||
|
intensity: number | null
|
||||||
|
pack_id: string
|
||||||
|
text: string
|
||||||
|
partner_text: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
intensity?: number | null
|
||||||
|
pack_id: string
|
||||||
|
text: string
|
||||||
|
partner_text?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
intensity?: number | null
|
||||||
|
pack_id?: string
|
||||||
|
text?: string
|
||||||
|
partner_text?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "questions_pack_id_fkey"
|
||||||
|
columns: ["pack_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "question_packs"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
responses: {
|
||||||
|
Row: {
|
||||||
|
answer: Database["public"]["Enums"]["answer_type"]
|
||||||
|
couple_id: string
|
||||||
|
created_at: string | null
|
||||||
|
id: string
|
||||||
|
question_id: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
answer: Database["public"]["Enums"]["answer_type"]
|
||||||
|
couple_id: string
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
question_id: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
answer?: Database["public"]["Enums"]["answer_type"]
|
||||||
|
couple_id?: string
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
question_id?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "responses_couple_id_fkey"
|
||||||
|
columns: ["couple_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "couples"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "responses_question_id_fkey"
|
||||||
|
columns: ["question_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "questions"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Views: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
Functions: {
|
||||||
|
get_recommended_questions: {
|
||||||
|
Args: {
|
||||||
|
target_pack_id?: string
|
||||||
|
}
|
||||||
|
Returns: {
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
partner_text: string | null
|
||||||
|
is_two_part: boolean
|
||||||
|
pack_id: string
|
||||||
|
intensity: number
|
||||||
|
partner_answered: boolean
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Enums: {
|
||||||
|
answer_type: "yes" | "no" | "maybe"
|
||||||
|
match_type: "yes_yes" | "yes_maybe" | "maybe_maybe"
|
||||||
|
}
|
||||||
|
CompositeTypes: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicSchema = Database[Extract<keyof Database, "public">]
|
||||||
|
|
||||||
|
export type Tables<
|
||||||
|
PublicTableNameOrOptions extends
|
||||||
|
| keyof (PublicSchema["Tables"] & PublicSchema["Views"])
|
||||||
|
| { schema: keyof Database },
|
||||||
|
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
||||||
|
? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
|
||||||
|
Database[PublicTableNameOrOptions["schema"]]["Views"])
|
||||||
|
: never = never,
|
||||||
|
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
||||||
|
? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
|
||||||
|
Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
||||||
|
Row: infer R
|
||||||
|
}
|
||||||
|
? R
|
||||||
|
: never
|
||||||
|
: PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] &
|
||||||
|
PublicSchema["Views"])
|
||||||
|
? (PublicSchema["Tables"] &
|
||||||
|
PublicSchema["Views"])[PublicTableNameOrOptions] extends {
|
||||||
|
Row: infer R
|
||||||
|
}
|
||||||
|
? R
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type TablesInsert<
|
||||||
|
PublicTableNameOrOptions extends
|
||||||
|
| keyof PublicSchema["Tables"]
|
||||||
|
| { schema: keyof Database },
|
||||||
|
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
||||||
|
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
|
||||||
|
: never = never,
|
||||||
|
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
||||||
|
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||||
|
Insert: infer I
|
||||||
|
}
|
||||||
|
? I
|
||||||
|
: never
|
||||||
|
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
|
||||||
|
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
|
||||||
|
Insert: infer I
|
||||||
|
}
|
||||||
|
? I
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type TablesUpdate<
|
||||||
|
PublicTableNameOrOptions extends
|
||||||
|
| keyof PublicSchema["Tables"]
|
||||||
|
| { schema: keyof Database },
|
||||||
|
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
||||||
|
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
|
||||||
|
: never = never,
|
||||||
|
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
||||||
|
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||||
|
Update: infer U
|
||||||
|
}
|
||||||
|
? U
|
||||||
|
: never
|
||||||
|
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
|
||||||
|
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
|
||||||
|
Update: infer U
|
||||||
|
}
|
||||||
|
? U
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type Enums<
|
||||||
|
PublicEnumNameOrOptions extends
|
||||||
|
| keyof PublicSchema["Enums"]
|
||||||
|
| { schema: keyof Database },
|
||||||
|
EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
|
||||||
|
? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
|
||||||
|
: never = never,
|
||||||
|
> = PublicEnumNameOrOptions extends { schema: keyof Database }
|
||||||
|
? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
||||||
|
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
|
||||||
|
? PublicSchema["Enums"][PublicEnumNameOrOptions]
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type CompositeTypes<
|
||||||
|
PublicCompositeTypeNameOrOptions extends
|
||||||
|
| keyof PublicSchema["CompositeTypes"]
|
||||||
|
| { schema: keyof Database },
|
||||||
|
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||||
|
schema: keyof Database
|
||||||
|
}
|
||||||
|
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
||||||
|
: never = never,
|
||||||
|
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
|
||||||
|
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
||||||
|
: PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"]
|
||||||
|
? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
||||||
|
: never
|
||||||
|
|
||||||
|
export const Constants = {
|
||||||
|
public: {
|
||||||
|
Enums: {
|
||||||
|
answer_type: ["yes", "no", "maybe"],
|
||||||
|
match_type: ["yes_yes", "yes_maybe", "maybe_maybe"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": [
|
||||||
|
"ES2020"
|
||||||
|
],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-native",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
],
|
||||||
|
"extends": "expo/tsconfig.base"
|
||||||
|
}
|
||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
[project]
|
||||||
|
id = "REPLACE_WITH_YOUR_PROJECT_ID"
|
||||||
|
name = "Sauci"
|
||||||
|
|
||||||
|
[api]
|
||||||
|
enabled = true
|
||||||
|
port = 54321
|
||||||
|
schemas = ["public", "graphql_public"]
|
||||||
|
extra_search_path = ["public", "extensions"]
|
||||||
|
max_rows = 1000
|
||||||
|
|
||||||
|
[db]
|
||||||
|
port = 54322
|
||||||
|
shadow_port = 54320
|
||||||
|
major_version = 15
|
||||||
|
|
||||||
|
[studio]
|
||||||
|
enabled = true
|
||||||
|
port = 54323
|
||||||
|
api_url = "http://localhost"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
enabled = true
|
||||||
|
site_url = "exp://localhost:8081"
|
||||||
|
additional_redirect_urls = ["sauci://auth/callback"]
|
||||||
|
jwt_expiry = 3600
|
||||||
|
enable_refresh_token_rotation = true
|
||||||
|
refresh_token_reuse_interval = 10
|
||||||
|
enable_signup = true
|
||||||
|
|
||||||
|
[auth.external.google]
|
||||||
|
enabled = true
|
||||||
|
client_id = "env(GOOGLE_CLIENT_ID)"
|
||||||
|
secret = "env(GOOGLE_CLIENT_SECRET)"
|
||||||
|
|
||||||
|
[auth.external.apple]
|
||||||
|
enabled = true
|
||||||
|
client_id = "env(APPLE_CLIENT_ID)"
|
||||||
|
secret = "env(APPLE_CLIENT_SECRET)"
|
||||||
|
|
||||||
|
[auth.email]
|
||||||
|
enable_signup = true
|
||||||
|
double_confirm_changes = true
|
||||||
|
enable_confirmations = false
|
||||||
|
|
||||||
|
[auth.sms]
|
||||||
|
enable_signup = false
|
||||||
|
enable_confirmations = false
|
||||||
|
|
||||||
|
[functions]
|
||||||
|
verify_jwt = true
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface JoinCoupleBody {
|
||||||
|
invite_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
// Handle CORS preflight
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response("ok", { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = createClient(
|
||||||
|
Deno.env.get("SUPABASE_URL")!,
|
||||||
|
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get user from JWT
|
||||||
|
const authHeader = req.headers.get("Authorization");
|
||||||
|
if (!authHeader) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Missing authorization header" }),
|
||||||
|
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: { user }, error: authError } = await supabase.auth.getUser(
|
||||||
|
authHeader.replace("Bearer ", "")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (authError || !user) {
|
||||||
|
console.error("Auth Error:", authError);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Invalid token: ${authError?.message || 'No user found'}` }),
|
||||||
|
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
if (method === "POST") {
|
||||||
|
// Check if user is already in a couple
|
||||||
|
const { data: existingProfile } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("couple_id")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (existingProfile?.couple_id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "You are already in a couple" }),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
if (body.invite_code) {
|
||||||
|
// Join existing couple
|
||||||
|
const { invite_code }: JoinCoupleBody = body;
|
||||||
|
|
||||||
|
// Find couple by invite code
|
||||||
|
const { data: couple, error: coupleError } = await supabase
|
||||||
|
.from("couples")
|
||||||
|
.select("id")
|
||||||
|
.eq("invite_code", invite_code.toLowerCase())
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (coupleError || !couple) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Invalid invite code" }),
|
||||||
|
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if couple already has 2 members
|
||||||
|
const { count } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("*", { count: "exact", head: true })
|
||||||
|
.eq("couple_id", couple.id);
|
||||||
|
|
||||||
|
if (count && count >= 2) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "This couple already has two partners" }),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user's profile with couple_id
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({ couple_id: couple.id })
|
||||||
|
.eq("id", user.id);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Failed to join couple" }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true, couple_id: couple.id }),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Create new couple
|
||||||
|
const { data: couple, error: createError } = await supabase
|
||||||
|
.from("couples")
|
||||||
|
.insert({})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (createError || !couple) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Failed to create couple" }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user's profile with couple_id
|
||||||
|
await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({ couple_id: couple.id })
|
||||||
|
.eq("id", user.id);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
couple_id: couple.id,
|
||||||
|
invite_code: couple.invite_code
|
||||||
|
}),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "DELETE") {
|
||||||
|
// Leave couple
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({ couple_id: null })
|
||||||
|
.eq("id", user.id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Failed to leave couple" }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true }),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Method not allowed" }),
|
||||||
|
{ status: 405, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Internal server error" }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface NotificationBody {
|
||||||
|
couple_id: string;
|
||||||
|
match_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExpoPushMessage {
|
||||||
|
to: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
sound?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
|
||||||
|
const response = await fetch("https://exp.host/--/api/v2/push/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(messages),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
console.error("Expo push error:", error);
|
||||||
|
throw new Error(`Failed to send push notification: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
// Handle CORS preflight
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response("ok", { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = createClient(
|
||||||
|
Deno.env.get("SUPABASE_URL")!,
|
||||||
|
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||||
|
);
|
||||||
|
|
||||||
|
const { couple_id, match_id }: NotificationBody = await req.json();
|
||||||
|
|
||||||
|
if (!couple_id || !match_id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Missing couple_id or match_id" }),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get push tokens for both partners
|
||||||
|
const { data: profiles, error: profilesError } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("push_token, name")
|
||||||
|
.eq("couple_id", couple_id)
|
||||||
|
.not("push_token", "is", null);
|
||||||
|
|
||||||
|
if (profilesError) {
|
||||||
|
console.error("Error fetching profiles:", profilesError);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Failed to fetch profiles" }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profiles || profiles.length === 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: false, message: "No push tokens found" }),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get match details for the notification
|
||||||
|
const { data: match } = await supabase
|
||||||
|
.from("matches")
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
question:questions(text)
|
||||||
|
`)
|
||||||
|
.eq("id", match_id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
// Build push messages
|
||||||
|
const messages: ExpoPushMessage[] = profiles
|
||||||
|
.filter((p) => p.push_token)
|
||||||
|
.map((profile) => ({
|
||||||
|
to: profile.push_token!,
|
||||||
|
title: "It's a match! 💕",
|
||||||
|
body: match?.question?.text
|
||||||
|
? `You both agreed: "${match.question.text.substring(0, 50)}..."`
|
||||||
|
: "You and your partner agreed on something new!",
|
||||||
|
sound: "default",
|
||||||
|
data: { match_id, type: "match" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Send notifications
|
||||||
|
await sendExpoPushNotifications(messages);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true, sent: messages.length }),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Internal server error" }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Answer = "yes" | "no" | "maybe";
|
||||||
|
type MatchType = "yes_yes" | "yes_maybe" | "maybe_maybe";
|
||||||
|
|
||||||
|
interface SubmitResponseBody {
|
||||||
|
question_id: string;
|
||||||
|
answer: Answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateMatch(a1: Answer, a2: Answer): MatchType | null {
|
||||||
|
if (a1 === "no" || a2 === "no") return null;
|
||||||
|
if (a1 === "yes" && a2 === "yes") return "yes_yes";
|
||||||
|
if (a1 === "maybe" && a2 === "maybe") return "maybe_maybe";
|
||||||
|
return "yes_maybe";
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
// Handle CORS preflight
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response("ok", { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = createClient(
|
||||||
|
Deno.env.get("SUPABASE_URL")!,
|
||||||
|
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get user from JWT
|
||||||
|
const authHeader = req.headers.get("Authorization");
|
||||||
|
if (!authHeader) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Missing authorization header" }),
|
||||||
|
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: { user }, error: authError } = await supabase.auth.getUser(
|
||||||
|
authHeader.replace("Bearer ", "")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (authError || !user) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Invalid token" }),
|
||||||
|
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
const { question_id, answer }: SubmitResponseBody = await req.json();
|
||||||
|
|
||||||
|
if (!question_id || !answer) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Missing question_id or answer" }),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's profile and couple
|
||||||
|
const { data: profile, error: profileError } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("couple_id")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (profileError || !profile?.couple_id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "User is not in a couple" }),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save or update response
|
||||||
|
const { data: response, error: responseError } = await supabase
|
||||||
|
.from("responses")
|
||||||
|
.upsert(
|
||||||
|
{
|
||||||
|
user_id: user.id,
|
||||||
|
question_id,
|
||||||
|
couple_id: profile.couple_id,
|
||||||
|
answer,
|
||||||
|
},
|
||||||
|
{ onConflict: "user_id,question_id" }
|
||||||
|
)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (responseError) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Failed to save response", details: responseError }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for partner's response
|
||||||
|
const { data: partnerResponse } = await supabase
|
||||||
|
.from("responses")
|
||||||
|
.select("answer")
|
||||||
|
.eq("couple_id", profile.couple_id)
|
||||||
|
.eq("question_id", question_id)
|
||||||
|
.neq("user_id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
let match = null;
|
||||||
|
|
||||||
|
if (partnerResponse) {
|
||||||
|
const matchType = calculateMatch(answer, partnerResponse.answer as Answer);
|
||||||
|
|
||||||
|
if (matchType) {
|
||||||
|
// Create or update match
|
||||||
|
const { data: newMatch, error: matchError } = await supabase
|
||||||
|
.from("matches")
|
||||||
|
.upsert(
|
||||||
|
{
|
||||||
|
couple_id: profile.couple_id,
|
||||||
|
question_id,
|
||||||
|
match_type: matchType,
|
||||||
|
is_new: true,
|
||||||
|
},
|
||||||
|
{ onConflict: "couple_id,question_id" }
|
||||||
|
)
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
question:questions(*)
|
||||||
|
`)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!matchError) {
|
||||||
|
match = newMatch;
|
||||||
|
|
||||||
|
// Trigger push notification
|
||||||
|
try {
|
||||||
|
await supabase.functions.invoke("send-notification", {
|
||||||
|
body: { couple_id: profile.couple_id, match_id: newMatch.id },
|
||||||
|
});
|
||||||
|
} catch (notifyError) {
|
||||||
|
console.error("Failed to send notification:", notifyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ response, match }),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Internal server error" }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
-- Enable necessary extensions
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Create custom types
|
||||||
|
CREATE TYPE public.answer_type AS ENUM ('yes', 'no', 'maybe');
|
||||||
|
CREATE TYPE public.match_type AS ENUM ('yes_yes', 'yes_maybe', 'maybe_maybe');
|
||||||
|
|
||||||
|
-- Couples table (must be created first due to FK references)
|
||||||
|
CREATE TABLE public.couples (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invite_code TEXT UNIQUE DEFAULT substr(md5(random()::text), 1, 8),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User profiles (extends auth.users)
|
||||||
|
CREATE TABLE public.profiles (
|
||||||
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
push_token TEXT,
|
||||||
|
is_premium BOOLEAN DEFAULT FALSE,
|
||||||
|
couple_id UUID REFERENCES public.couples(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Question packs
|
||||||
|
CREATE TABLE public.question_packs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
icon TEXT,
|
||||||
|
is_premium BOOLEAN DEFAULT FALSE,
|
||||||
|
is_public BOOLEAN DEFAULT TRUE,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Questions within packs
|
||||||
|
CREATE TABLE public.questions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
pack_id UUID NOT NULL REFERENCES public.question_packs(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
intensity INT DEFAULT 1 CHECK (intensity BETWEEN 1 AND 5),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User responses to questions
|
||||||
|
CREATE TABLE public.responses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||||
|
question_id UUID NOT NULL REFERENCES public.questions(id) ON DELETE CASCADE,
|
||||||
|
couple_id UUID NOT NULL REFERENCES public.couples(id) ON DELETE CASCADE,
|
||||||
|
answer public.answer_type NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, question_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Matches between partners
|
||||||
|
CREATE TABLE public.matches (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
couple_id UUID NOT NULL REFERENCES public.couples(id) ON DELETE CASCADE,
|
||||||
|
question_id UUID NOT NULL REFERENCES public.questions(id) ON DELETE CASCADE,
|
||||||
|
match_type public.match_type NOT NULL,
|
||||||
|
is_new BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(couple_id, question_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for common queries
|
||||||
|
CREATE INDEX idx_profiles_couple_id ON public.profiles(couple_id);
|
||||||
|
CREATE INDEX idx_questions_pack_id ON public.questions(pack_id);
|
||||||
|
CREATE INDEX idx_responses_couple_question ON public.responses(couple_id, question_id);
|
||||||
|
CREATE INDEX idx_matches_couple_id ON public.matches(couple_id);
|
||||||
|
CREATE INDEX idx_matches_is_new ON public.matches(is_new) WHERE is_new = TRUE;
|
||||||
|
|
||||||
|
-- Function to auto-create profile on signup
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.profiles (id, name, avatar_url)
|
||||||
|
VALUES (
|
||||||
|
NEW.id,
|
||||||
|
COALESCE(NEW.raw_user_meta_data->>'name', NEW.raw_user_meta_data->>'full_name'),
|
||||||
|
NEW.raw_user_meta_data->>'avatar_url'
|
||||||
|
);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Trigger to create profile on signup
|
||||||
|
CREATE TRIGGER on_auth_user_created
|
||||||
|
AFTER INSERT ON auth.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||||||
|
|
||||||
|
-- Function to update updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION public.update_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger to auto-update updated_at on profiles
|
||||||
|
CREATE TRIGGER update_profiles_updated_at
|
||||||
|
BEFORE UPDATE ON public.profiles
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
-- Enable Row Level Security on all tables
|
||||||
|
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.couples ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.question_packs ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.questions ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.responses ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.matches ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Profiles policies
|
||||||
|
CREATE POLICY "Users can view own profile"
|
||||||
|
ON public.profiles FOR SELECT
|
||||||
|
USING (auth.uid() = id);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can update own profile"
|
||||||
|
ON public.profiles FOR UPDATE
|
||||||
|
USING (auth.uid() = id);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can view partner profile"
|
||||||
|
ON public.profiles FOR SELECT
|
||||||
|
USING (
|
||||||
|
couple_id IS NOT NULL AND
|
||||||
|
couple_id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Couples policies
|
||||||
|
CREATE POLICY "Users can view own couple"
|
||||||
|
ON public.couples FOR SELECT
|
||||||
|
USING (id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid()));
|
||||||
|
|
||||||
|
CREATE POLICY "Authenticated users can create couples"
|
||||||
|
ON public.couples FOR INSERT
|
||||||
|
WITH CHECK (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
CREATE POLICY "Users can view couple by invite code"
|
||||||
|
ON public.couples FOR SELECT
|
||||||
|
USING (TRUE); -- Allow reading by invite_code for joining
|
||||||
|
|
||||||
|
-- Question packs policies (public packs visible to all)
|
||||||
|
CREATE POLICY "Anyone can view public packs"
|
||||||
|
ON public.question_packs FOR SELECT
|
||||||
|
USING (is_public = TRUE);
|
||||||
|
|
||||||
|
CREATE POLICY "Premium users can view premium packs"
|
||||||
|
ON public.question_packs FOR SELECT
|
||||||
|
USING (
|
||||||
|
is_premium = FALSE OR
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE id = auth.uid() AND is_premium = TRUE)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Questions policies
|
||||||
|
CREATE POLICY "Anyone can view questions in visible packs"
|
||||||
|
ON public.questions FOR SELECT
|
||||||
|
USING (
|
||||||
|
pack_id IN (
|
||||||
|
SELECT id FROM public.question_packs
|
||||||
|
WHERE is_public = TRUE OR (
|
||||||
|
is_premium = TRUE AND
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE id = auth.uid() AND is_premium = TRUE)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Responses policies
|
||||||
|
CREATE POLICY "Users can view own responses"
|
||||||
|
ON public.responses FOR SELECT
|
||||||
|
USING (user_id = auth.uid());
|
||||||
|
|
||||||
|
CREATE POLICY "Users can insert own responses"
|
||||||
|
ON public.responses FOR INSERT
|
||||||
|
WITH CHECK (user_id = auth.uid());
|
||||||
|
|
||||||
|
CREATE POLICY "Users can update own responses"
|
||||||
|
ON public.responses FOR UPDATE
|
||||||
|
USING (user_id = auth.uid());
|
||||||
|
|
||||||
|
-- Matches policies
|
||||||
|
CREATE POLICY "Users can view couple matches"
|
||||||
|
ON public.matches FOR SELECT
|
||||||
|
USING (
|
||||||
|
couple_id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can update match seen status"
|
||||||
|
ON public.matches FOR UPDATE
|
||||||
|
USING (
|
||||||
|
couple_id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid())
|
||||||
|
);
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
-- Seed data: Initial question packs for testing
|
||||||
|
|
||||||
|
-- Starter Pack (free)
|
||||||
|
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
|
||||||
|
VALUES (
|
||||||
|
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||||
|
'Starter Pack',
|
||||||
|
'Perfect for couples just getting started. Light and playful questions to break the ice.',
|
||||||
|
'🌟',
|
||||||
|
FALSE,
|
||||||
|
TRUE,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.questions (pack_id, text, intensity) VALUES
|
||||||
|
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you like to try a couples massage together?', 1),
|
||||||
|
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you enjoy a candlelit dinner at home?', 1),
|
||||||
|
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you like to slow dance in the living room?', 1),
|
||||||
|
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you enjoy a bath or shower together?', 2),
|
||||||
|
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you like to give your partner a back rub?', 1);
|
||||||
|
|
||||||
|
-- Date Night Pack (free)
|
||||||
|
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
|
||||||
|
VALUES (
|
||||||
|
'b2c3d4e5-f678-9012-bcde-f23456789012',
|
||||||
|
'Date Night',
|
||||||
|
'Spice up your date nights with these romantic suggestions.',
|
||||||
|
'🌹',
|
||||||
|
FALSE,
|
||||||
|
TRUE,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.questions (pack_id, text, intensity) VALUES
|
||||||
|
('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you enjoy stargazing together?', 1),
|
||||||
|
('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you like to cook a meal together?', 1),
|
||||||
|
('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you enjoy a picnic in the park?', 1),
|
||||||
|
('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you like to watch a romantic movie together?', 1),
|
||||||
|
('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you enjoy a weekend getaway?', 2);
|
||||||
|
|
||||||
|
-- Adventure Pack (premium)
|
||||||
|
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
|
||||||
|
VALUES (
|
||||||
|
'c3d4e5f6-7890-1234-cdef-345678901234',
|
||||||
|
'Adventure',
|
||||||
|
'For couples ready to explore more adventurous activities together.',
|
||||||
|
'🔥',
|
||||||
|
TRUE,
|
||||||
|
TRUE,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.questions (pack_id, text, intensity) VALUES
|
||||||
|
('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you like to try role-playing scenarios?', 3),
|
||||||
|
('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you enjoy using massage oils?', 2),
|
||||||
|
('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you like to write love letters to each other?', 2),
|
||||||
|
('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you enjoy a blindfolded experience?', 3),
|
||||||
|
('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you like to plan a surprise for your partner?', 2);
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
-- Add Adult / After Dark pack
|
||||||
|
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
|
||||||
|
VALUES (
|
||||||
|
'd4e5f607-8901-2345-defa-456789012345',
|
||||||
|
'After Dark',
|
||||||
|
'Our most intimate and explicit questions for couples ready to explore their deepest fantasies.',
|
||||||
|
'😈',
|
||||||
|
TRUE,
|
||||||
|
TRUE,
|
||||||
|
4
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.questions (pack_id, text, intensity) VALUES
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try using toys during intimacy?', 3),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy exploring BDSM (e.g., light bondage)?', 3),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to watch adult content together?', 3),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy trying intimacy in a new or daring location?', 3),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to experiment with temperature play (e.g., ice or wax)?', 3),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy role-playing a "stranger" or "first date" scenario?', 3),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try "dirty talk" or more explicit communication?', 3),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy a full-body sensory massage with oils and feathers?', 3),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to explore "edging" or delayed gratification?', 3),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy sharing your deepest, most explicit fantasies?', 3),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try shower sex or water play?', 3),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy a roleplay involving a "student/teacher" or similar dynamic?', 3),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to experiment with leather, latex, or other textures?', 3),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy trying "impact play" (e.g., spanking)?', 3),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy a weekend dedicated entirely to intimacy and exploration?', 2),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try "blindfolded feeding" or sensory food play?', 2),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy exploring your partner''s "no-go" zones with permission?', 3),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try "synchronized breathing" or Tantric techniques?', 2),
|
||||||
|
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy making a "video for us" to watch later?', 3);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Fix infinite recursion in profiles RLS policy
|
||||||
|
|
||||||
|
-- Create a secure function to look up couple_id without triggering recursion
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_auth_user_couple_id()
|
||||||
|
RETURNS UUID
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT couple_id FROM public.profiles WHERE id = auth.uid();
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Drop the recursive policy
|
||||||
|
DROP POLICY IF EXISTS "Users can view partner profile" ON public.profiles;
|
||||||
|
|
||||||
|
-- Recreate the policy using the secure function
|
||||||
|
CREATE POLICY "Users can view partner profile"
|
||||||
|
ON public.profiles FOR SELECT
|
||||||
|
USING (
|
||||||
|
couple_id IS NOT NULL AND
|
||||||
|
couple_id = public.get_auth_user_couple_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Also fix the Matches policy which has the same issue
|
||||||
|
DROP POLICY IF EXISTS "Users can view couple matches" ON public.matches;
|
||||||
|
|
||||||
|
CREATE POLICY "Users can view couple matches"
|
||||||
|
ON public.matches FOR SELECT
|
||||||
|
USING (
|
||||||
|
couple_id = public.get_auth_user_couple_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- And the update matches policy
|
||||||
|
DROP POLICY IF EXISTS "Users can update match seen status" ON public.matches;
|
||||||
|
|
||||||
|
CREATE POLICY "Users can update match seen status"
|
||||||
|
ON public.matches FOR UPDATE
|
||||||
|
USING (
|
||||||
|
couple_id = public.get_auth_user_couple_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- And the Couples policy
|
||||||
|
DROP POLICY IF EXISTS "Users can view own couple" ON public.couples;
|
||||||
|
|
||||||
|
CREATE POLICY "Users can view own couple"
|
||||||
|
ON public.couples FOR SELECT
|
||||||
|
USING (
|
||||||
|
id = public.get_auth_user_couple_id()
|
||||||
|
);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- Create couple_packs table to track enabled packs per couple
|
||||||
|
CREATE TABLE public.couple_packs (
|
||||||
|
couple_id UUID NOT NULL REFERENCES public.couples(id) ON DELETE CASCADE,
|
||||||
|
pack_id UUID NOT NULL REFERENCES public.question_packs(id) ON DELETE CASCADE,
|
||||||
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (couple_id, pack_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS Policies
|
||||||
|
ALTER TABLE public.couple_packs ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Couples can view their own pack settings
|
||||||
|
CREATE POLICY "Couples can view their own pack settings"
|
||||||
|
ON public.couple_packs
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.profiles
|
||||||
|
WHERE profiles.id = auth.uid()
|
||||||
|
AND profiles.couple_id = couple_packs.couple_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Couples can insert/update their own pack settings
|
||||||
|
CREATE POLICY "Couples can modify their own pack settings"
|
||||||
|
ON public.couple_packs
|
||||||
|
FOR ALL
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.profiles
|
||||||
|
WHERE profiles.id = auth.uid()
|
||||||
|
AND profiles.couple_id = couple_packs.couple_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Function to handle default packs for new couples (Optional, but good UX)
|
||||||
|
-- For now, we'll just assume if no record exists, it's not enabled?
|
||||||
|
-- Or maybe better: if no record exists, it IS enabled if it's not premium?
|
||||||
|
-- Actually, the user wants to "select which packs are active".
|
||||||
|
-- Let's stick to explicit records.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- Create messages table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
match_id UUID NOT NULL REFERENCES public.matches(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
content TEXT,
|
||||||
|
media_path TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
CONSTRAINT content_or_media CHECK (content IS NOT NULL OR media_path IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS for messages
|
||||||
|
ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Users can view messages in their matches"
|
||||||
|
ON public.messages
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.matches m
|
||||||
|
WHERE m.id = messages.match_id
|
||||||
|
AND m.couple_id IN (
|
||||||
|
SELECT couple_id FROM public.profiles
|
||||||
|
WHERE id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can insert messages in their matches"
|
||||||
|
ON public.messages
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
auth.uid() = user_id AND
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.matches m
|
||||||
|
WHERE m.id = messages.match_id
|
||||||
|
AND m.couple_id IN (
|
||||||
|
SELECT couple_id FROM public.profiles
|
||||||
|
WHERE id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Storage bucket for chat media
|
||||||
|
INSERT INTO storage.buckets (id, name, public)
|
||||||
|
VALUES ('chat-media', 'chat-media', true)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Storage policies
|
||||||
|
CREATE POLICY "Users can upload chat media"
|
||||||
|
ON storage.objects
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
bucket_id = 'chat-media' AND
|
||||||
|
auth.role() = 'authenticated'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can view chat media"
|
||||||
|
ON storage.objects
|
||||||
|
FOR SELECT
|
||||||
|
USING (bucket_id = 'chat-media');
|
||||||
|
|
||||||
|
-- Realtime
|
||||||
|
alter publication supabase_realtime add table messages;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Add read_at column
|
||||||
|
ALTER TABLE public.messages ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Allow users to update read_at for messages where they are NOT the sender
|
||||||
|
CREATE POLICY "Users can mark messages as read"
|
||||||
|
ON public.messages
|
||||||
|
FOR UPDATE
|
||||||
|
USING (
|
||||||
|
auth.uid() != user_id AND
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.matches m
|
||||||
|
WHERE m.id = messages.match_id
|
||||||
|
AND m.couple_id IN (
|
||||||
|
SELECT couple_id FROM public.profiles
|
||||||
|
WHERE id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
auth.uid() != user_id
|
||||||
|
);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- Add Explicit / Wild pack
|
||||||
|
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
|
||||||
|
VALUES (
|
||||||
|
'e5f6a7b8-9012-3456-abcd-567890123456',
|
||||||
|
'Explicit & Wild',
|
||||||
|
'Unleash your wildest desires with our most explicit questions. Uses direct language for couples who want to get straight to the point.',
|
||||||
|
'🔥',
|
||||||
|
TRUE,
|
||||||
|
TRUE,
|
||||||
|
5
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.questions (pack_id, text, intensity) VALUES
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you let me cum on your face?', 5),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to swallow my cum?', 5),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you like to be fucked rough, with hair pulling and choking?', 5),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to use a butt plug during sex?', 4),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you let me treat you like a slut in the bedroom?', 5),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to make a sex tape where we show everything?', 4),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you be into a gangbang fantasy?', 5),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want a creampie (cum inside)?', 5),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you like to use a strap-on to fuck me?', 5),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to rim my asshole?', 5),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to try fisting?', 5),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you lick my cum off your own body?', 5),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to deep throat until you gag?', 5),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you like to watch me have sex with someone else?', 5),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to have anal sex until I cum?', 5),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you be down for a threesome?', 5),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want me to edge you until you are begging to cum?', 4),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you like to wake me up with oral sex?', 4),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to be spanked hard enough to leave marks?', 4),
|
||||||
|
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you enjoy being tied up and helpless while I use you?', 5);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id UUID,
|
||||||
|
text TEXT,
|
||||||
|
pack_id UUID,
|
||||||
|
intensity INT,
|
||||||
|
partner_answered BOOLEAN
|
||||||
|
) LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id UUID;
|
||||||
|
v_couple_id UUID;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
|
||||||
|
-- Get couple_id
|
||||||
|
SELECT p.couple_id INTO v_couple_id
|
||||||
|
FROM public.profiles p
|
||||||
|
WHERE p.id = v_user_id;
|
||||||
|
|
||||||
|
IF v_couple_id IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
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 (
|
||||||
|
-- Case 0: Specific pack requested
|
||||||
|
SELECT target_pack_id AS pack_id
|
||||||
|
WHERE target_pack_id IS NOT NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- Case 1: No specific pack, config exists -> use enabled packs
|
||||||
|
SELECT cp.pack_id
|
||||||
|
FROM public.couple_packs cp
|
||||||
|
WHERE target_pack_id IS NULL
|
||||||
|
AND cp.couple_id = v_couple_id AND cp.enabled = TRUE
|
||||||
|
AND EXISTS (SELECT 1 FROM user_config_exists)
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- Case 2: No specific pack, no config -> use all public packs
|
||||||
|
SELECT p.id AS pack_id
|
||||||
|
FROM public.question_packs p
|
||||||
|
WHERE target_pack_id IS NULL
|
||||||
|
AND p.is_public = TRUE
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
q.id,
|
||||||
|
q.text,
|
||||||
|
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
|
||||||
|
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
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
-- Add partner_text column to questions table
|
||||||
|
ALTER TABLE public.questions ADD COLUMN IF NOT EXISTS partner_text TEXT;
|
||||||
|
|
||||||
|
-- Update get_recommended_questions to include partner_text
|
||||||
|
-- Must drop first because return type changes
|
||||||
|
DROP FUNCTION IF EXISTS public.get_recommended_questions(uuid);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id UUID,
|
||||||
|
text TEXT,
|
||||||
|
partner_text TEXT,
|
||||||
|
pack_id UUID,
|
||||||
|
intensity INT,
|
||||||
|
partner_answered BOOLEAN
|
||||||
|
) LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id UUID;
|
||||||
|
v_couple_id UUID;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
|
||||||
|
-- Get couple_id
|
||||||
|
SELECT p.couple_id INTO v_couple_id
|
||||||
|
FROM public.profiles p
|
||||||
|
WHERE p.id = v_user_id;
|
||||||
|
|
||||||
|
IF v_couple_id IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
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 (
|
||||||
|
-- Case 0: Specific pack requested
|
||||||
|
SELECT target_pack_id AS pack_id
|
||||||
|
WHERE target_pack_id IS NOT NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- Case 1: No specific pack, config exists -> use enabled packs
|
||||||
|
SELECT cp.pack_id
|
||||||
|
FROM public.couple_packs cp
|
||||||
|
WHERE target_pack_id IS NULL
|
||||||
|
AND cp.couple_id = v_couple_id AND cp.enabled = TRUE
|
||||||
|
AND EXISTS (SELECT 1 FROM user_config_exists)
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- Case 2: No specific pack, no config -> use all public packs
|
||||||
|
SELECT p.id AS pack_id
|
||||||
|
FROM public.question_packs p
|
||||||
|
WHERE target_pack_id IS NULL
|
||||||
|
AND p.is_public = TRUE
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
q.id,
|
||||||
|
q.text,
|
||||||
|
q.partner_text,
|
||||||
|
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
|
||||||
|
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
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Add a new pack for two-part questions
|
||||||
|
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
|
||||||
|
VALUES (
|
||||||
|
'd4e5f6f7-8901-2345-defa-456789012345',
|
||||||
|
'Giving & Receiving',
|
||||||
|
'Explore different roles and dynamics with these two-part questions.',
|
||||||
|
'🌓',
|
||||||
|
FALSE,
|
||||||
|
TRUE,
|
||||||
|
4
|
||||||
|
) ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO public.questions (pack_id, text, partner_text, intensity) VALUES
|
||||||
|
('d4e5f6f7-8901-2345-defa-456789012345', 'Finish on your partner''s face', 'Have your partner finish on your face', 4),
|
||||||
|
('d4e5f6f7-8901-2345-defa-456789012345', 'Give your partner a full body massage', 'Receive a full body massage from your partner', 2),
|
||||||
|
('d4e5f6f7-8901-2345-defa-456789012345', 'Tie your partner up', 'Be tied up by your partner', 4),
|
||||||
|
('d4e5f6f7-8901-2345-defa-456789012345', 'Blindfold your partner', 'Be blindfolded by your partner', 3),
|
||||||
|
('d4e5f6f7-8901-2345-defa-456789012345', 'Feed your partner while they are blindfolded', 'Be fed by your partner while blindfolded', 2);
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
-- Update get_recommended_questions to handle two-part questions role assignment
|
||||||
|
-- And return is_two_part for prioritization in the app
|
||||||
|
DROP FUNCTION IF EXISTS public.get_recommended_questions(uuid);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id UUID,
|
||||||
|
text TEXT,
|
||||||
|
partner_text TEXT, -- Keep this for reference if needed, but 'text' will be the display one
|
||||||
|
is_two_part BOOLEAN,
|
||||||
|
pack_id UUID,
|
||||||
|
intensity INT,
|
||||||
|
partner_answered BOOLEAN
|
||||||
|
) LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id UUID;
|
||||||
|
v_couple_id UUID;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
|
||||||
|
-- Get couple_id
|
||||||
|
SELECT p.couple_id INTO v_couple_id
|
||||||
|
FROM public.profiles p
|
||||||
|
WHERE p.id = v_user_id;
|
||||||
|
|
||||||
|
IF v_couple_id IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
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
|
||||||
|
UNION ALL
|
||||||
|
SELECT cp.pack_id FROM public.couple_packs cp
|
||||||
|
WHERE target_pack_id IS NULL AND cp.couple_id = v_couple_id AND cp.enabled = 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 NOT EXISTS (SELECT 1 FROM user_config_exists)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
q.id,
|
||||||
|
-- Logic: Show partner_text IF partner has already answered, otherwise show text
|
||||||
|
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
|
||||||
|
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
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Allow users to view their partner's responses in addition to their own
|
||||||
|
DROP POLICY IF EXISTS "Users can view own responses" ON public.responses;
|
||||||
|
|
||||||
|
CREATE POLICY "Users can view couple responses"
|
||||||
|
ON public.responses FOR SELECT
|
||||||
|
USING (
|
||||||
|
user_id = auth.uid() OR
|
||||||
|
couple_id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid())
|
||||||
|
);
|
||||||
Generated
+18803
File diff suppressed because it is too large.
Load diff
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "sauci",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"apps/admin",
|
||||||
|
"apps/supabase",
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "turbo dev",
|
||||||
|
"build": "turbo build",
|
||||||
|
"lint": "turbo lint",
|
||||||
|
"clean": "turbo clean",
|
||||||
|
"typecheck": "turbo typecheck"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"turbo": "^2.3.3",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"packageManager": "npm@10.2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@sauci/shared",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./types": "./src/types/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint src/"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from './types';
|
||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
// User profile (extends Supabase auth.users)
|
||||||
|
export interface Profile {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
push_token: string | null;
|
||||||
|
is_premium: boolean;
|
||||||
|
couple_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Couple pairing
|
||||||
|
export interface Couple {
|
||||||
|
id: string;
|
||||||
|
invite_code: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Question pack
|
||||||
|
export interface QuestionPack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
is_premium: boolean;
|
||||||
|
is_public: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual question
|
||||||
|
export interface Question {
|
||||||
|
id: string;
|
||||||
|
pack_id: string;
|
||||||
|
text: string;
|
||||||
|
intensity: 1 | 2 | 3 | 4 | 5;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User's answer type
|
||||||
|
export type AnswerType = 'yes' | 'no' | 'maybe';
|
||||||
|
|
||||||
|
// Match type when both partners agree
|
||||||
|
export type MatchType = 'yes_yes' | 'yes_maybe' | 'maybe_maybe';
|
||||||
|
|
||||||
|
// User response to a question
|
||||||
|
export interface Response {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
question_id: string;
|
||||||
|
couple_id: string;
|
||||||
|
answer: AnswerType;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match between partners
|
||||||
|
export interface Match {
|
||||||
|
id: string;
|
||||||
|
couple_id: string;
|
||||||
|
question_id: string;
|
||||||
|
match_type: MatchType;
|
||||||
|
is_new: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended match with question details
|
||||||
|
export interface MatchWithQuestion extends Match {
|
||||||
|
question: Question;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pack with progress info
|
||||||
|
export interface PackProgress {
|
||||||
|
pack: QuestionPack;
|
||||||
|
total_questions: number;
|
||||||
|
answered_questions: number;
|
||||||
|
matches_count: number;
|
||||||
|
}
|
||||||
Executable
+336
@@ -0,0 +1,336 @@
|
|||||||
|
export type Json =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| { [key: string]: Json | undefined }
|
||||||
|
| Json[]
|
||||||
|
|
||||||
|
export type Database = {
|
||||||
|
// Allows to automatically instantiate createClient with right options
|
||||||
|
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
||||||
|
__InternalSupabase: {
|
||||||
|
PostgrestVersion: "14.1"
|
||||||
|
}
|
||||||
|
public: {
|
||||||
|
Tables: {
|
||||||
|
couples: {
|
||||||
|
Row: {
|
||||||
|
created_at: string | null
|
||||||
|
id: string
|
||||||
|
invite_code: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
invite_code?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
invite_code?: string | null
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
matches: {
|
||||||
|
Row: {
|
||||||
|
couple_id: string
|
||||||
|
created_at: string | null
|
||||||
|
id: string
|
||||||
|
is_new: boolean | null
|
||||||
|
match_type: Database["public"]["Enums"]["match_type"]
|
||||||
|
question_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
couple_id: string
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
is_new?: boolean | null
|
||||||
|
match_type: Database["public"]["Enums"]["match_type"]
|
||||||
|
question_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
couple_id?: string
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
is_new?: boolean | null
|
||||||
|
match_type?: Database["public"]["Enums"]["match_type"]
|
||||||
|
question_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "matches_couple_id_fkey"
|
||||||
|
columns: ["couple_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "couples"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "matches_question_id_fkey"
|
||||||
|
columns: ["question_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "questions"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
profiles: {
|
||||||
|
Row: {
|
||||||
|
avatar_url: string | null
|
||||||
|
couple_id: string | null
|
||||||
|
created_at: string | null
|
||||||
|
id: string
|
||||||
|
is_premium: boolean | null
|
||||||
|
name: string | null
|
||||||
|
push_token: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
avatar_url?: string | null
|
||||||
|
couple_id?: string | null
|
||||||
|
created_at?: string | null
|
||||||
|
id: string
|
||||||
|
is_premium?: boolean | null
|
||||||
|
name?: string | null
|
||||||
|
push_token?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
avatar_url?: string | null
|
||||||
|
couple_id?: string | null
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
is_premium?: boolean | null
|
||||||
|
name?: string | null
|
||||||
|
push_token?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "profiles_couple_id_fkey"
|
||||||
|
columns: ["couple_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "couples"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "profiles_id_fkey"
|
||||||
|
columns: ["id"]
|
||||||
|
isOneToOne: true
|
||||||
|
referencedRelation: "users"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
question_packs: {
|
||||||
|
Row: {
|
||||||
|
created_at: string | null
|
||||||
|
description: string | null
|
||||||
|
icon: string | null
|
||||||
|
id: string
|
||||||
|
is_premium: boolean | null
|
||||||
|
is_public: boolean | null
|
||||||
|
name: string
|
||||||
|
sort_order: number | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string | null
|
||||||
|
description?: string | null
|
||||||
|
icon?: string | null
|
||||||
|
id?: string
|
||||||
|
is_premium?: boolean | null
|
||||||
|
is_public?: boolean | null
|
||||||
|
name: string
|
||||||
|
sort_order?: number | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string | null
|
||||||
|
description?: string | null
|
||||||
|
icon?: string | null
|
||||||
|
id?: string
|
||||||
|
is_premium?: boolean | null
|
||||||
|
is_public?: boolean | null
|
||||||
|
name?: string
|
||||||
|
sort_order?: number | null
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
questions: {
|
||||||
|
Row: {
|
||||||
|
created_at: string | null
|
||||||
|
id: string
|
||||||
|
intensity: number | null
|
||||||
|
pack_id: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
intensity?: number | null
|
||||||
|
pack_id: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
intensity?: number | null
|
||||||
|
pack_id?: string
|
||||||
|
text?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "questions_pack_id_fkey"
|
||||||
|
columns: ["pack_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "question_packs"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
responses: {
|
||||||
|
Row: {
|
||||||
|
answer: Database["public"]["Enums"]["answer_type"]
|
||||||
|
couple_id: string
|
||||||
|
created_at: string | null
|
||||||
|
id: string
|
||||||
|
question_id: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
answer: Database["public"]["Enums"]["answer_type"]
|
||||||
|
couple_id: string
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
question_id: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
answer?: Database["public"]["Enums"]["answer_type"]
|
||||||
|
couple_id?: string
|
||||||
|
created_at?: string | null
|
||||||
|
id?: string
|
||||||
|
question_id?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "responses_couple_id_fkey"
|
||||||
|
columns: ["couple_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "couples"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "responses_question_id_fkey"
|
||||||
|
columns: ["question_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "questions"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "responses_user_id_fkey"
|
||||||
|
columns: ["user_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "profiles"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Views: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
Functions: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
Enums: {
|
||||||
|
answer_type: "yes" | "no" | "maybe"
|
||||||
|
match_type: "yes_yes" | "yes_maybe" | "maybe_maybe"
|
||||||
|
}
|
||||||
|
CompositeTypes: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicSchema = Database[Extract<keyof Database, "public">]
|
||||||
|
|
||||||
|
export type Tables<
|
||||||
|
PublicTableNameOrOptions extends
|
||||||
|
| keyof (PublicSchema["Tables"] & PublicSchema["Views"])
|
||||||
|
| { schema: keyof Database },
|
||||||
|
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
||||||
|
? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
|
||||||
|
Database[PublicTableNameOrOptions["schema"]]["Views"])
|
||||||
|
: never = never,
|
||||||
|
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
||||||
|
? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
|
||||||
|
Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
||||||
|
Row: infer R
|
||||||
|
}
|
||||||
|
? R
|
||||||
|
: never
|
||||||
|
: PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] &
|
||||||
|
PublicSchema["Views"])
|
||||||
|
? (PublicSchema["Tables"] &
|
||||||
|
PublicSchema["Views"])[PublicTableNameOrOptions] extends {
|
||||||
|
Row: infer R
|
||||||
|
}
|
||||||
|
? R
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type TablesInsert<
|
||||||
|
PublicTableNameOrOptions extends
|
||||||
|
| keyof PublicSchema["Tables"]
|
||||||
|
| { schema: keyof Database },
|
||||||
|
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
||||||
|
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
|
||||||
|
: never = never,
|
||||||
|
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
||||||
|
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||||
|
Insert: infer I
|
||||||
|
}
|
||||||
|
? I
|
||||||
|
: never
|
||||||
|
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
|
||||||
|
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
|
||||||
|
Insert: infer I
|
||||||
|
}
|
||||||
|
? I
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type TablesUpdate<
|
||||||
|
PublicTableNameOrOptions extends
|
||||||
|
| keyof PublicSchema["Tables"]
|
||||||
|
| { schema: keyof Database },
|
||||||
|
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
||||||
|
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
|
||||||
|
: never = never,
|
||||||
|
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
||||||
|
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||||
|
Update: infer U
|
||||||
|
}
|
||||||
|
? U
|
||||||
|
: never
|
||||||
|
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
|
||||||
|
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
|
||||||
|
Update: infer U
|
||||||
|
}
|
||||||
|
? U
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type Enums<
|
||||||
|
PublicEnumNameOrOptions extends
|
||||||
|
| keyof PublicSchema["Enums"]
|
||||||
|
| { schema: keyof Database },
|
||||||
|
EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
|
||||||
|
? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
|
||||||
|
: never = never,
|
||||||
|
> = PublicEnumNameOrOptions extends { schema: keyof Database }
|
||||||
|
? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
||||||
|
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
|
||||||
|
? PublicSchema["Enums"][PublicEnumNameOrOptions]
|
||||||
|
: never
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": [
|
||||||
|
"ES2020"
|
||||||
|
],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
],
|
||||||
|
"extends": "expo/tsconfig.base"
|
||||||
|
}
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"globalDependencies": [
|
||||||
|
"**/.env.*local"
|
||||||
|
],
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": [
|
||||||
|
"^build"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
"dist/**",
|
||||||
|
".next/**",
|
||||||
|
"!.next/cache/**",
|
||||||
|
".expo/**"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"dependsOn": [
|
||||||
|
"^build"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"typecheck": {
|
||||||
|
"dependsOn": [
|
||||||
|
"^build"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"clean": {
|
||||||
|
"cache": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user