Update
No files matched your search
@@ -15,6 +15,7 @@ import { AdminsPage } from '@/pages/admins/AdminsPage';
|
|||||||
import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
|
import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
|
||||||
import { FeedbackPage } from '@/pages/FeedbackPage';
|
import { FeedbackPage } from '@/pages/FeedbackPage';
|
||||||
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
|
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
|
||||||
|
import { UserActivityPage } from '@/pages/activity/UserActivityPage';
|
||||||
import { AiSettingsPage } from '@/pages/AiSettingsPage';
|
import { AiSettingsPage } from '@/pages/AiSettingsPage';
|
||||||
import { AppSettingsPage } from '@/pages/AppSettingsPage';
|
import { AppSettingsPage } from '@/pages/AppSettingsPage';
|
||||||
|
|
||||||
@@ -96,6 +97,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/activity"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_ACTIVITY}>
|
||||||
|
<UserActivityPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* System */}
|
{/* System */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
X,
|
X,
|
||||||
|
Home,
|
||||||
|
Activity,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
@@ -29,6 +31,14 @@ interface NavItem {
|
|||||||
requiredPermission?: PermissionKey;
|
requiredPermission?: PermissionKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dashboardNav: NavItem[] = [
|
||||||
|
{
|
||||||
|
label: 'Dashboard',
|
||||||
|
href: '/',
|
||||||
|
icon: <Home className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const contentNav: NavItem[] = [
|
const contentNav: NavItem[] = [
|
||||||
{
|
{
|
||||||
label: 'Categories',
|
label: 'Categories',
|
||||||
@@ -45,6 +55,12 @@ const usersNav: NavItem[] = [
|
|||||||
icon: <Users className="h-5 w-5" />,
|
icon: <Users className="h-5 w-5" />,
|
||||||
requiredPermission: PERMISSION_KEYS.VIEW_USERS,
|
requiredPermission: PERMISSION_KEYS.VIEW_USERS,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'User Activity',
|
||||||
|
href: '/activity',
|
||||||
|
icon: <Activity className="h-5 w-5" />,
|
||||||
|
requiredPermission: PERMISSION_KEYS.VIEW_ACTIVITY,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Usage Insights',
|
label: 'Usage Insights',
|
||||||
href: '/usage-insights',
|
href: '/usage-insights',
|
||||||
@@ -112,7 +128,9 @@ export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
const NavLink = ({ item }: { item: NavItem }) => {
|
const NavLink = ({ item }: { item: NavItem }) => {
|
||||||
if (item.requiredPermission && !hasPermission(item.requiredPermission)) return null;
|
if (item.requiredPermission && !hasPermission(item.requiredPermission)) return null;
|
||||||
|
|
||||||
const isActive = location.pathname.startsWith(item.href);
|
const isActive = item.href === '/'
|
||||||
|
? location.pathname === '/'
|
||||||
|
: location.pathname.startsWith(item.href);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -214,6 +232,7 @@ export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className="flex-1 space-y-6 overflow-auto px-3 py-2">
|
<div className="flex-1 space-y-6 overflow-auto px-3 py-2">
|
||||||
|
<NavSection title="Dashboard" items={dashboardNav} />
|
||||||
<NavSection title="Content" items={contentNav} />
|
<NavSection title="Content" items={contentNav} />
|
||||||
<NavSection title="Users" items={usersNav} />
|
<NavSection title="Users" items={usersNav} />
|
||||||
<NavSection title="System" items={systemNav} />
|
<NavSection title="System" items={systemNav} />
|
||||||
@@ -310,6 +329,7 @@ export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className="flex-1 space-y-6 overflow-auto px-3 py-4">
|
<div className="flex-1 space-y-6 overflow-auto px-3 py-4">
|
||||||
|
<NavSection title="Dashboard" items={dashboardNav} />
|
||||||
<NavSection title="Content" items={contentNav} />
|
<NavSection title="Content" items={contentNav} />
|
||||||
<NavSection title="Users" items={usersNav} />
|
<NavSection title="Users" items={usersNav} />
|
||||||
<NavSection title="System" items={systemNav} />
|
<NavSection title="System" items={systemNav} />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const PERMISSION_KEYS = {
|
|||||||
VIEW_MEDIA: 'view_media',
|
VIEW_MEDIA: 'view_media',
|
||||||
VIEW_MATCHES: 'view_matches',
|
VIEW_MATCHES: 'view_matches',
|
||||||
VIEW_RESPONSES: 'view_responses',
|
VIEW_RESPONSES: 'view_responses',
|
||||||
|
VIEW_ACTIVITY: 'view_activity',
|
||||||
// System permissions
|
// System permissions
|
||||||
MANAGE_CODES: 'manage_codes',
|
MANAGE_CODES: 'manage_codes',
|
||||||
MANAGE_ADMINS: 'manage_admins',
|
MANAGE_ADMINS: 'manage_admins',
|
||||||
@@ -38,6 +39,7 @@ export const PERMISSION_METADATA: Record<PermissionKey, { label: string; descrip
|
|||||||
[PERMISSION_KEYS.VIEW_MEDIA]: { label: 'Can view media', description: 'View user photos and media', group: 'users' },
|
[PERMISSION_KEYS.VIEW_MEDIA]: { label: 'Can view media', description: 'View user photos and media', group: 'users' },
|
||||||
[PERMISSION_KEYS.VIEW_MATCHES]: { label: 'Can view matches', description: 'View user matches and match details', group: 'users' },
|
[PERMISSION_KEYS.VIEW_MATCHES]: { label: 'Can view matches', description: 'View user matches and match details', group: 'users' },
|
||||||
[PERMISSION_KEYS.VIEW_RESPONSES]: { label: 'Can view responses', description: 'View user question responses', group: 'users' },
|
[PERMISSION_KEYS.VIEW_RESPONSES]: { label: 'Can view responses', description: 'View user question responses', group: 'users' },
|
||||||
|
[PERMISSION_KEYS.VIEW_ACTIVITY]: { label: 'Can view activity', description: 'View user activity feed (responses, matches, messages, signups)', group: 'users' },
|
||||||
[PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' },
|
[PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' },
|
||||||
[PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' },
|
[PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' },
|
||||||
[PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' },
|
[PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' },
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Search, Crown, Users, ChevronRight, HardDrive } from 'lucide-react';
|
import { Search, Crown, Users, ChevronRight, HardDrive, MailWarning } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
interface Profile {
|
interface Profile {
|
||||||
@@ -27,6 +27,8 @@ interface Profile {
|
|||||||
is_premium: boolean | null;
|
is_premium: boolean | null;
|
||||||
couple_id: string | null;
|
couple_id: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
|
last_sign_in_at: string | null;
|
||||||
|
email_confirmed_at: string | null;
|
||||||
storage_bytes?: number;
|
storage_bytes?: number;
|
||||||
partner_is_premium?: boolean;
|
partner_is_premium?: boolean;
|
||||||
}
|
}
|
||||||
@@ -48,11 +50,9 @@ export function UsersPage() {
|
|||||||
const fetchProfiles = useCallback(async () => {
|
const fetchProfiles = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch profiles
|
// Fetch profiles with auth info (includes last_sign_in_at)
|
||||||
const { data: profilesData, error } = await supabase
|
const { data: profilesData, error } = await supabase
|
||||||
.from('profiles')
|
.rpc('get_profiles_with_auth_info');
|
||||||
.select('*')
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
@@ -197,13 +197,14 @@ export function UsersPage() {
|
|||||||
<TableHead>Couple</TableHead>
|
<TableHead>Couple</TableHead>
|
||||||
<TableHead>Storage</TableHead>
|
<TableHead>Storage</TableHead>
|
||||||
<TableHead>Joined</TableHead>
|
<TableHead>Joined</TableHead>
|
||||||
|
<TableHead>Last Active</TableHead>
|
||||||
<TableHead className="w-12"></TableHead>
|
<TableHead className="w-12"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredProfiles.length === 0 ? (
|
{filteredProfiles.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||||
{search ? 'No users found matching your search.' : 'No users yet.'}
|
{search ? 'No users found matching your search.' : 'No users yet.'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -224,7 +225,14 @@ export function UsersPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell className="text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
{profile.email || '—'}
|
{profile.email || '—'}
|
||||||
|
{profile.email && !profile.email_confirmed_at && (
|
||||||
|
<span title="Email not verified">
|
||||||
|
<MailWarning className="h-3.5 w-3.5 text-amber-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{profile.is_premium ? (
|
{profile.is_premium ? (
|
||||||
@@ -268,6 +276,11 @@ export function UsersPage() {
|
|||||||
? format(new Date(profile.created_at), 'MMM d, yyyy')
|
? format(new Date(profile.created_at), 'MMM d, yyyy')
|
||||||
: '—'}
|
: '—'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{profile.last_sign_in_at
|
||||||
|
? format(new Date(profile.last_sign_in_at), 'MMM d, yyyy h:mm a')
|
||||||
|
: 'Never'}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link to={`/users/${profile.id}`}>
|
<Link to={`/users/${profile.id}`}>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ android {
|
|||||||
applicationId 'com.sauci.app'
|
applicationId 'com.sauci.app'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 2
|
versionCode 3
|
||||||
versionName "1.0.0"
|
versionName "1.0.0"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -104,10 +104,10 @@ android {
|
|||||||
keyPassword 'android'
|
keyPassword 'android'
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
storeFile file('upload-keystore.jks')
|
storeFile file('../sauci-release.keystore')
|
||||||
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "changeit"
|
storePassword "create01"
|
||||||
keyAlias 'upload'
|
keyAlias 'sauci'
|
||||||
keyPassword System.getenv("KEY_PASSWORD") ?: "changeit"
|
keyPassword "create01"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -12,7 +12,12 @@
|
|||||||
"package_name": "com.sauci.app"
|
"package_name": "com.sauci.app"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oauth_client": [],
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "764866133492-e78o2rh3rdjc4rfj2j6r2j77b065kggm.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
"api_key": [
|
"api_key": [
|
||||||
{
|
{
|
||||||
"current_key": "AIzaSyDOAqIXAd_dy7L9ZflPvNkJdNXWMaGZ1O4"
|
"current_key": "AIzaSyDOAqIXAd_dy7L9ZflPvNkJdNXWMaGZ1O4"
|
||||||
@@ -20,7 +25,12 @@
|
|||||||
],
|
],
|
||||||
"services": {
|
"services": {
|
||||||
"appinvite_service": {
|
"appinvite_service": {
|
||||||
"other_platform_oauth_client": []
|
"other_platform_oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "764866133492-e78o2rh3rdjc4rfj2j6r2j77b065kggm.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 167 B |
|
After Width: | Height: | Size: 483 B |
|
After Width: | Height: | Size: 332 B |
|
After Width: | Height: | Size: 653 B |
|
After Width: | Height: | Size: 201 B |
|
After Width: | Height: | Size: 138 B |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 928 B |
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 645 B |
|
After Width: | Height: | Size: 465 B |
|
After Width: | Height: | Size: 258 B |
|
After Width: | Height: | Size: 220 B |
|
After Width: | Height: | Size: 100 B |
|
After Width: | Height: | Size: 266 B |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 334 B |
|
After Width: | Height: | Size: 728 B |
|
After Width: | Height: | Size: 134 B |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 469 B |
|
After Width: | Height: | Size: 141 B |
|
After Width: | Height: | Size: 188 B |
|
After Width: | Height: | Size: 502 B |
@@ -0,0 +1 @@
|
|||||||
|
{"version":0,"bundler":"metro","fileMetadata":{"android":{"bundle":"_expo/static/js/android/entry-295eb642e86589562852e8b2e825bdc0.hbc","assets":[{"path":"assets\\778ffc9fe8773a878e9c30a6304784de","ext":"png"},{"path":"assets\\c79c3606a1cf168006ad3979763c7e0c","ext":"png"},{"path":"assets\\02bc1fa7c0313217bde2d65ccbff40c9","ext":"png"},{"path":"assets\\35ba0eaec5a4f5ed12ca16fabeae451d","ext":"png"},{"path":"assets\\0a328cd9c1afd0afe8e3b1ec5165b1b4","ext":"png"},{"path":"assets\\61ca7e64b7d605716c57706cef640b9a","ext":"png"},{"path":"assets\\3cd68ccdb8938e3711da2e8831b85493","ext":"png"},{"path":"assets\\aff2c65b39a296d4f7e96d0f58169170","ext":"png"},{"path":"assets\\d8e7601e3df962f83c62371ac14964d8","ext":"png"},{"path":"assets\\3cd68ccdb8938e3711da2e8831b85493","ext":"png"},{"path":"assets\\0747a1317bbe9c6fc340b889ef8ab3ae","ext":"png"},{"path":"assets\\d84e297c3b3e49a614248143d53e40ca","ext":"png"},{"path":"assets\\1190ab078c57159f4245a328118fcd9a","ext":"png"},{"path":"assets\\78c625386b4d0690b421eb0fc78f7b9c","ext":"png"},{"path":"assets\\0747a1317bbe9c6fc340b889ef8ab3ae","ext":"png"},{"path":"assets\\4403c6117ec30c859bc95d70ce4a71d3","ext":"png"},{"path":"assets\\069d99eb1fa6712c0b9034a58c6b57dd","ext":"png"},{"path":"assets\\c3273c9e5321f20d1e42c2efae2578c4","ext":"png"},{"path":"assets\\286d67d3f74808a60a78d3ebf1a5fb57","ext":"png"},{"path":"assets\\d1ea1496f9057eb392d5bbf3732a61b7","ext":"png"},{"path":"assets\\19eeb73b9593a38f8e9f418337fc7d10","ext":"png"},{"path":"assets\\ab19f4cbc543357183a20571f68380a3","ext":"png"},{"path":"assets\\d8b800c443b8972542883e0b9de2bdc6","ext":"png"},{"path":"assets\\412dd9275b6b48ad28f5e3d81bb1f626","ext":"png"},{"path":"assets\\20e71bdf79e3a97bf55fd9e164041578","ext":"png"},{"path":"assets\\3a2ba31570920eeb9b1d217cabe58315","ext":"ttf"},{"path":"assets\\31b5ffea3daddc69dd01a1f3d6cf63c5","ext":"ttf"},{"path":"assets\\140c53a7643ea949007aa9a282153849","ext":"ttf"},{"path":"assets\\a76d309774d33d9856f650bed4292a23","ext":"ttf"},{"path":"assets\\b49ae8ab2dbccb02c4d11caaacf09eab","ext":"ttf"},{"path":"assets\\b06871f281fee6b241d60582ae9369b9","ext":"ttf"},{"path":"assets\\1f77739ca9ff2188b539c36f30ffa2be","ext":"ttf"},{"path":"assets\\605ed7926cf39a2ad5ec2d1f9d391d3d","ext":"ttf"},{"path":"assets\\3b89dd103490708d19a95adcae52210e","ext":"ttf"},{"path":"assets\\370dd5af19f8364907b6e2c41f45dbbf","ext":"ttf"},{"path":"assets\\adec7d6f310bc577f05e8fe06a5daccf","ext":"ttf"},{"path":"assets\\56c8d80832e37783f12c05db7c8849e2","ext":"ttf"},{"path":"assets\\e20945d7c929279ef7a6f1db184a4470","ext":"ttf"},{"path":"assets\\6148e7019854f3bde85b633cb88f3c25","ext":"ttf"},{"path":"assets\\b62641afc9ab487008e996a5c5865e56","ext":"ttf"},{"path":"assets\\4e85bc9ebe07e0340c9c4fc2f6c38908","ext":"ttf"},{"path":"assets\\f7c53c47a66934504fcbc7cc164895a7","ext":"ttf"},{"path":"assets\\d2285965fe34b05465047401b8595dd0","ext":"ttf"},{"path":"assets\\1681f34aaca71b8dfb70756bca331eb2","ext":"ttf"},{"path":"assets\\cf3578021b5094cf7ab6322f2ee617a0","ext":"png"}]}}}
|
||||||
@@ -66,7 +66,13 @@
|
|||||||
],
|
],
|
||||||
"@sentry/react-native",
|
"@sentry/react-native",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"expo-asset"
|
"expo-asset",
|
||||||
|
[
|
||||||
|
"@react-native-google-signin/google-signin",
|
||||||
|
{
|
||||||
|
"iosUrlScheme": "com.googleusercontent.apps.861972684340-0u2as8i5equfhmhmvjihottkf0ig7237"
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -84,9 +84,10 @@ export default function AppLayout() {
|
|||||||
const wentToBackgroundAt = useRef<number | null>(null);
|
const wentToBackgroundAt = useRef<number | null>(null);
|
||||||
|
|
||||||
// Check if we're on screens that should hide the tab bar
|
// Check if we're on screens that should hide the tab bar
|
||||||
const isOnOnboarding = segments.includes("onboarding");
|
const segmentStrings = segments as string[];
|
||||||
const isOnPairing = segments.includes("pairing");
|
const isOnOnboarding = segmentStrings.includes("onboarding");
|
||||||
const isOnChat = segments.includes("chat");
|
const isOnPairing = segmentStrings.includes("pairing");
|
||||||
|
const isOnChat = segmentStrings.includes("chat");
|
||||||
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat;
|
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat;
|
||||||
|
|
||||||
// Redirect to login when not authenticated, or to onboarding if not completed
|
// Redirect to login when not authenticated, or to onboarding if not completed
|
||||||
@@ -309,10 +310,16 @@ export default function AppLayout() {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
|
// Handle the joined question data from the query
|
||||||
|
const questionData = match.question;
|
||||||
|
const questionText = Array.isArray(questionData)
|
||||||
|
? questionData[0]?.text
|
||||||
|
: (questionData as { text: string } | null)?.text;
|
||||||
|
|
||||||
addMessage({
|
addMessage({
|
||||||
...newMessage,
|
...newMessage,
|
||||||
delivered_at: newMessage.delivered_at || new Date().toISOString(),
|
delivered_at: newMessage.delivered_at || new Date().toISOString(),
|
||||||
match: match as { id: string; question: { text: string } },
|
match: { id: match.id, question: { text: questionText || "" } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ export default function OnboardingScreen() {
|
|||||||
Show me everything
|
Show me everything
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.explicitDescription}>
|
<Text style={styles.explicitDescription}>
|
||||||
Include 18+ question packs
|
Include spicy question packs
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={[
|
<View style={[
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function PairingScreen() {
|
|||||||
// Redirect if already paired
|
// Redirect if already paired
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (couple && partner) {
|
if (couple && partner) {
|
||||||
router.replace("/(app)/");
|
router.replace("/(app)");
|
||||||
}
|
}
|
||||||
}, [couple, partner]);
|
}, [couple, partner]);
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ export default function PairingScreen() {
|
|||||||
Events.coupleJoined();
|
Events.coupleJoined();
|
||||||
|
|
||||||
Alert.alert("Success", "You are now paired!", [
|
Alert.alert("Success", "You are now paired!", [
|
||||||
{ text: "Let's Go", onPress: () => router.replace("/(app)/") }
|
{ text: "Let's Go", onPress: () => router.replace("/(app)") }
|
||||||
]);
|
]);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
Alert.alert("Error", getPairingError(error));
|
Alert.alert("Error", getPairingError(error));
|
||||||
|
|||||||
@@ -726,8 +726,8 @@ export default function ProfileScreen() {
|
|||||||
<Ionicons name="flame" size={20} color={colors.text} />
|
<Ionicons name="flame" size={20} color={colors.text} />
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<View style={styles.rowTextContainer}>
|
<View style={styles.rowTextContainer}>
|
||||||
<Text style={styles.rowValue}>Explicit Content</Text>
|
<Text style={styles.rowValue}>Spicy Content</Text>
|
||||||
<Text style={styles.rowLabel}>Show 18+ question packs</Text>
|
<Text style={styles.rowLabel}>Show spicy question packs</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -2,7 +2,17 @@ import { useState, useEffect, useRef } from "react";
|
|||||||
import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native";
|
import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native";
|
||||||
import { useLocalSearchParams, router } from "expo-router";
|
import { useLocalSearchParams, router } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
import Animated, {
|
||||||
|
FadeIn,
|
||||||
|
FadeInUp,
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withRepeat,
|
||||||
|
withSequence,
|
||||||
|
withTiming,
|
||||||
|
interpolate,
|
||||||
|
Easing,
|
||||||
|
} from "react-native-reanimated";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { supabase } from "../../src/lib/supabase";
|
import { supabase } from "../../src/lib/supabase";
|
||||||
import { usePacksStore, useAuthStore } from "../../src/store";
|
import { usePacksStore, useAuthStore } from "../../src/store";
|
||||||
@@ -27,6 +37,74 @@ export default function SwipeScreen() {
|
|||||||
const { partner, couple } = useAuthStore();
|
const { partner, couple } = useAuthStore();
|
||||||
const hasTrackedExhausted = useRef(false);
|
const hasTrackedExhausted = useRef(false);
|
||||||
|
|
||||||
|
// Ambient orb breathing animations
|
||||||
|
const orbBreathing1 = useSharedValue(0);
|
||||||
|
const orbBreathing2 = useSharedValue(0);
|
||||||
|
const orbDrift = useSharedValue(0);
|
||||||
|
const shimmerPosition = useSharedValue(-1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Primary orb breathing - 6 second cycle
|
||||||
|
orbBreathing1.value = withRepeat(
|
||||||
|
withSequence(
|
||||||
|
withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.sin) }),
|
||||||
|
withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.sin) })
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Secondary orb breathing - offset timing for variation
|
||||||
|
orbBreathing2.value = withRepeat(
|
||||||
|
withSequence(
|
||||||
|
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
|
||||||
|
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }),
|
||||||
|
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subtle vertical drift - 8 second cycle
|
||||||
|
orbDrift.value = withRepeat(
|
||||||
|
withSequence(
|
||||||
|
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }),
|
||||||
|
withTiming(0, { duration: 4000, easing: Easing.inOut(Easing.sin) })
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Progress bar shimmer sweep - 2.5 second cycle
|
||||||
|
shimmerPosition.value = withRepeat(
|
||||||
|
withTiming(2, { duration: 2500, easing: Easing.linear }),
|
||||||
|
-1,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const orbStyle1 = useAnimatedStyle(() => ({
|
||||||
|
opacity: interpolate(orbBreathing1.value, [0, 1], [0.25, 0.5]),
|
||||||
|
transform: [
|
||||||
|
{ translateY: interpolate(orbDrift.value, [0, 1], [0, -20]) },
|
||||||
|
{ scale: interpolate(orbBreathing1.value, [0, 1], [1, 1.1]) },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const orbStyle2 = useAnimatedStyle(() => ({
|
||||||
|
opacity: interpolate(orbBreathing2.value, [0, 1], [0.2, 0.4]),
|
||||||
|
transform: [
|
||||||
|
{ translateY: interpolate(orbDrift.value, [0, 1], [20, 0]) },
|
||||||
|
{ scale: interpolate(orbBreathing2.value, [0, 1], [1, 1.1]) },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const shimmerStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [
|
||||||
|
{ translateX: interpolate(shimmerPosition.value, [-1, 2], [-60, 220]) },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPacks().then(() => fetchQuestions());
|
fetchPacks().then(() => fetchQuestions());
|
||||||
checkTutorial();
|
checkTutorial();
|
||||||
@@ -323,6 +401,24 @@ export default function SwipeScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
|
{/* Ambient Orbs */}
|
||||||
|
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.primaryGlow, 'transparent']}
|
||||||
|
style={styles.orbGradient}
|
||||||
|
start={{ x: 0.5, y: 0.5 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
<Animated.View style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]} pointerEvents="none">
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.secondaryGlow, 'transparent']}
|
||||||
|
style={styles.orbGradient}
|
||||||
|
start={{ x: 0.5, y: 0.5 }}
|
||||||
|
end={{ x: 0, y: 0 }}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -333,7 +429,7 @@ export default function SwipeScreen() {
|
|||||||
<Text style={styles.progressText}>
|
<Text style={styles.progressText}>
|
||||||
{currentIndex + 1} of {questions.length}
|
{currentIndex + 1} of {questions.length}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.progressBar}>
|
<View style={[styles.progressBar, styles.progressBarEnhanced]}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.progressFill,
|
styles.progressFill,
|
||||||
@@ -346,6 +442,15 @@ export default function SwipeScreen() {
|
|||||||
end={{ x: 1, y: 0 }}
|
end={{ x: 1, y: 0 }}
|
||||||
style={styles.progressGradient}
|
style={styles.progressGradient}
|
||||||
/>
|
/>
|
||||||
|
{/* Shimmer sweep */}
|
||||||
|
<Animated.View style={[styles.progressShimmer, shimmerStyle]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['transparent', 'rgba(255,255,255,0.3)', 'transparent']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -353,9 +458,14 @@ export default function SwipeScreen() {
|
|||||||
|
|
||||||
{/* Card Stack */}
|
{/* Card Stack */}
|
||||||
<View style={styles.cardContainer}>
|
<View style={styles.cardContainer}>
|
||||||
{/* Background card (next) */}
|
{/* Third card (deepest) */}
|
||||||
|
{questions[currentIndex + 2] && (
|
||||||
|
<View style={[styles.stackCard, styles.stackCardThird]} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Second card */}
|
||||||
{questions[currentIndex + 1] && (
|
{questions[currentIndex + 1] && (
|
||||||
<View style={styles.backgroundCard} />
|
<View style={[styles.stackCard, styles.stackCardSecond]} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active card */}
|
{/* Active card */}
|
||||||
@@ -395,6 +505,26 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
|
// Ambient orbs - more visible
|
||||||
|
ambientOrb: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
borderRadius: 150,
|
||||||
|
},
|
||||||
|
orbTopRight: {
|
||||||
|
top: 60,
|
||||||
|
right: -40,
|
||||||
|
},
|
||||||
|
orbBottomLeft: {
|
||||||
|
bottom: 180,
|
||||||
|
left: -40,
|
||||||
|
},
|
||||||
|
orbGradient: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 150,
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingTop: 60,
|
paddingTop: 60,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
@@ -415,21 +545,35 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
|
progressBarEnhanced: {
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.borderLight,
|
||||||
|
...shadows.glow(colors.primaryGlow),
|
||||||
|
},
|
||||||
progressFill: {
|
progressFill: {
|
||||||
height: "100%",
|
height: "100%",
|
||||||
borderRadius: 3,
|
borderRadius: 4,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
progressGradient: {
|
progressGradient: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
borderRadius: 3,
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
progressShimmer: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 60,
|
||||||
|
height: '100%',
|
||||||
|
top: 0,
|
||||||
},
|
},
|
||||||
cardContainer: {
|
cardContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
backgroundCard: {
|
// Card stack
|
||||||
|
stackCard: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
width: "85%",
|
width: "85%",
|
||||||
height: 480,
|
height: 480,
|
||||||
@@ -437,10 +581,17 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: radius.xxl,
|
borderRadius: radius.xxl,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.glass.border,
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
stackCardSecond: {
|
||||||
transform: [{ scale: 0.95 }, { translateY: 12 }],
|
transform: [{ scale: 0.95 }, { translateY: 12 }],
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
...shadows.md,
|
...shadows.md,
|
||||||
},
|
},
|
||||||
|
stackCardThird: {
|
||||||
|
transform: [{ scale: 0.90 }, { translateY: 24 }],
|
||||||
|
opacity: 0.25,
|
||||||
|
...shadows.sm,
|
||||||
|
},
|
||||||
hintContainer: {
|
hintContainer: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingBottom: spacing.md,
|
paddingBottom: spacing.md,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import Animated, {
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
// Safely import Apple Authentication - may not be available in Expo Go
|
// Safely import Apple Authentication - may not be available in Expo Go
|
||||||
let AppleAuthentication: typeof import("expo-apple-authentication") | null = null;
|
let AppleAuthentication: typeof import("expo-apple-authentication") | null = null;
|
||||||
|
let GoogleSignin: typeof import("@react-native-google-signin/google-signin").GoogleSignin | null = null;
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
try {
|
try {
|
||||||
@@ -28,6 +29,20 @@ if (Platform.OS === "ios") {
|
|||||||
AppleAuthentication = null;
|
AppleAuthentication = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
try {
|
||||||
|
const googleModule = require("@react-native-google-signin/google-signin");
|
||||||
|
GoogleSignin = googleModule.GoogleSignin;
|
||||||
|
if (GoogleSignin) {
|
||||||
|
GoogleSignin.configure({
|
||||||
|
webClientId: "764866133492-e78o2rh3rdjc4rfj2j6r2j77b065kggm.apps.googleusercontent.com",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
GoogleSignin = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
import { supabase } from "../../src/lib/supabase";
|
import { supabase } from "../../src/lib/supabase";
|
||||||
import { getAuthError } from "../../src/lib/errors";
|
import { getAuthError } from "../../src/lib/errors";
|
||||||
import { Events } from "../../src/lib/analytics";
|
import { Events } from "../../src/lib/analytics";
|
||||||
@@ -55,6 +70,7 @@ export default function LoginScreen() {
|
|||||||
const [isSignUp, setIsSignUp] = useState(false);
|
const [isSignUp, setIsSignUp] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isAppleAuthAvailable, setIsAppleAuthAvailable] = useState(false);
|
const [isAppleAuthAvailable, setIsAppleAuthAvailable] = useState(false);
|
||||||
|
const [isGoogleAuthAvailable, setIsGoogleAuthAvailable] = useState(false);
|
||||||
const { isAuthenticated } = useAuthStore();
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
// Check Apple Auth availability asynchronously
|
// Check Apple Auth availability asynchronously
|
||||||
@@ -68,6 +84,13 @@ export default function LoginScreen() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Check Google Auth availability
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "android" && GoogleSignin) {
|
||||||
|
setIsGoogleAuthAvailable(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Cooldown timer for resend verification email
|
// Cooldown timer for resend verification email
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (resendCooldown <= 0) return;
|
if (resendCooldown <= 0) return;
|
||||||
@@ -301,6 +324,43 @@ export default function LoginScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGoogleSignIn = async () => {
|
||||||
|
console.log("[Google Sign In] Starting, isGoogleAuthAvailable:", isGoogleAuthAvailable, "GoogleSignin:", !!GoogleSignin);
|
||||||
|
|
||||||
|
if (isGoogleAuthAvailable && GoogleSignin) {
|
||||||
|
try {
|
||||||
|
console.log("[Google Sign In] Checking Play Services...");
|
||||||
|
await GoogleSignin.hasPlayServices();
|
||||||
|
|
||||||
|
console.log("[Google Sign In] Calling signIn...");
|
||||||
|
const response = await GoogleSignin.signIn();
|
||||||
|
console.log("[Google Sign In] Got response, type:", response.type);
|
||||||
|
|
||||||
|
if (response.type === "success" && response.data.idToken) {
|
||||||
|
console.log("[Google Sign In] Got idToken, signing in with Supabase...");
|
||||||
|
const { error } = await supabase.auth.signInWithIdToken({
|
||||||
|
provider: "google",
|
||||||
|
token: response.data.idToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.log("[Google Sign In] Supabase error:", error.message);
|
||||||
|
Alert.alert("Error", getAuthError(error));
|
||||||
|
} else {
|
||||||
|
Events.signIn("google");
|
||||||
|
}
|
||||||
|
} else if (response.type === "cancelled") {
|
||||||
|
console.log("[Google Sign In] User cancelled");
|
||||||
|
} else {
|
||||||
|
Alert.alert("Error", "No identity token received from Google");
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log("[Google Sign In] Caught error:", e.code, e.message);
|
||||||
|
Alert.alert("Error", "Google sign in failed. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isMagicLinkSent) {
|
if (isMagicLinkSent) {
|
||||||
return (
|
return (
|
||||||
<GradientBackground showAccent>
|
<GradientBackground showAccent>
|
||||||
@@ -540,7 +600,8 @@ export default function LoginScreen() {
|
|||||||
</GlassCard>
|
</GlassCard>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Social Sign In */}
|
{/* Social Sign In - iOS only */}
|
||||||
|
{Platform.OS === "ios" && (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(300).duration(600).springify()}
|
entering={FadeInDown.delay(300).duration(600).springify()}
|
||||||
style={styles.socialContainer}
|
style={styles.socialContainer}
|
||||||
@@ -572,6 +633,33 @@ export default function LoginScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Social Sign In - Android (Google) */}
|
||||||
|
{Platform.OS === "android" && isGoogleAuthAvailable && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(300).duration(600).springify()}
|
||||||
|
style={styles.socialContainer}
|
||||||
|
>
|
||||||
|
<View style={styles.divider}>
|
||||||
|
<View style={styles.dividerLine} />
|
||||||
|
<Text style={styles.dividerText}>or continue with</Text>
|
||||||
|
<View style={styles.dividerLine} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.socialButtons}>
|
||||||
|
<GlassButton
|
||||||
|
variant="secondary"
|
||||||
|
onPress={handleGoogleSignIn}
|
||||||
|
icon={<Ionicons name="logo-google" size={20} color={colors.text} />}
|
||||||
|
style={styles.socialButton}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Continue with Google
|
||||||
|
</GlassButton>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import React, { useEffect, useRef } from "react";
|
|||||||
import { Platform, View, AppState, AppStateStatus } from "react-native";
|
import { Platform, View, AppState, AppStateStatus } from "react-native";
|
||||||
|
|
||||||
// Suppress useLayoutEffect warning on server-side rendering
|
// Suppress useLayoutEffect warning on server-side rendering
|
||||||
if (Platform.OS === "web" && typeof window === "undefined") {
|
if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
|
||||||
React.useLayoutEffect = useEffect;
|
React.useLayoutEffect = useEffect;
|
||||||
// Also silence the warning in case some libraries ignore the monkey-patch
|
// Also silence the warning in case some libraries ignore the monkey-patch
|
||||||
const originalError = console.error;
|
const originalError = console.error;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function Index() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise go to home
|
// Otherwise go to home
|
||||||
return <Redirect href="/(app)/" />;
|
return <Redirect href="/(app)" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@expo/ngrok": "^4.1.0",
|
"@expo/ngrok": "^4.1.0",
|
||||||
"@expo/vector-icons": "~14.0.4",
|
"@expo/vector-icons": "~14.0.4",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
|
"@react-native-google-signin/google-signin": "^16.1.1",
|
||||||
"@react-navigation/bottom-tabs": "^7.0.0",
|
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||||
"@react-navigation/native": "^7.0.0",
|
"@react-navigation/native": "^7.0.0",
|
||||||
"@react-navigation/native-stack": "^7.0.0",
|
"@react-navigation/native-stack": "^7.0.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { View, Text, StyleSheet, Platform, useWindowDimensions } from "react-native";
|
import { View, Text, StyleSheet, Platform, useWindowDimensions } from "react-native";
|
||||||
import { Gesture, GestureDetector, TouchableOpacity } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector, TouchableOpacity } from "react-native-gesture-handler";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
@@ -7,11 +7,14 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
withSpring,
|
withSpring,
|
||||||
|
withRepeat,
|
||||||
|
withSequence,
|
||||||
runOnJS,
|
runOnJS,
|
||||||
interpolate,
|
interpolate,
|
||||||
Extrapolate,
|
Extrapolate,
|
||||||
withTiming,
|
withTiming,
|
||||||
interpolateColor,
|
interpolateColor,
|
||||||
|
Easing,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme";
|
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme";
|
||||||
@@ -48,15 +51,32 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
|||||||
const translateY = useSharedValue(0);
|
const translateY = useSharedValue(0);
|
||||||
const rotation = useSharedValue(0);
|
const rotation = useSharedValue(0);
|
||||||
const scale = useSharedValue(1);
|
const scale = useSharedValue(1);
|
||||||
|
const shadowExpand = useSharedValue(0);
|
||||||
|
const idleBreathing = useSharedValue(0);
|
||||||
|
const isGesturing = useSharedValue(false);
|
||||||
|
|
||||||
const context = useSharedValue({ x: 0, y: 0 });
|
const context = useSharedValue({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
// Subtle idle breathing animation
|
||||||
|
useEffect(() => {
|
||||||
|
idleBreathing.value = withRepeat(
|
||||||
|
withSequence(
|
||||||
|
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
|
||||||
|
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const gesture = Gesture.Pan()
|
const gesture = Gesture.Pan()
|
||||||
.activeOffsetX([-15, 15])
|
.activeOffsetX([-15, 15])
|
||||||
.activeOffsetY([-15, 15])
|
.activeOffsetY([-15, 15])
|
||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
context.value = { x: translateX.value, y: translateY.value };
|
context.value = { x: translateX.value, y: translateY.value };
|
||||||
scale.value = withTiming(1.02, { duration: animations.timing.fast });
|
scale.value = withTiming(1.02, { duration: animations.timing.fast });
|
||||||
|
shadowExpand.value = withTiming(1, { duration: animations.timing.fast });
|
||||||
|
isGesturing.value = true;
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
translateX.value = event.translationX + context.value.x;
|
translateX.value = event.translationX + context.value.x;
|
||||||
@@ -70,6 +90,8 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
|||||||
})
|
})
|
||||||
.onEnd(() => {
|
.onEnd(() => {
|
||||||
scale.value = withTiming(1, { duration: animations.timing.fast });
|
scale.value = withTiming(1, { duration: animations.timing.fast });
|
||||||
|
shadowExpand.value = withTiming(0, { duration: animations.timing.fast });
|
||||||
|
isGesturing.value = false;
|
||||||
|
|
||||||
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
|
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
|
||||||
const direction = translateX.value > 0 ? "right" : "left";
|
const direction = translateX.value > 0 ? "right" : "left";
|
||||||
@@ -94,13 +116,28 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
// Apply idle breathing only when card is at rest
|
||||||
|
const idleOffset = isGesturing.value ? 0 : interpolate(idleBreathing.value, [0, 1], [0, -6]);
|
||||||
|
|
||||||
|
return {
|
||||||
transform: [
|
transform: [
|
||||||
{ translateX: translateX.value },
|
{ translateX: translateX.value },
|
||||||
{ translateY: translateY.value },
|
{ translateY: translateY.value + idleOffset },
|
||||||
{ rotate: `${rotation.value}deg` },
|
{ rotate: `${rotation.value}deg` },
|
||||||
{ scale: scale.value },
|
{ scale: scale.value },
|
||||||
],
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dynamic shadow for card lift effect
|
||||||
|
const cardShadowStyle = useAnimatedStyle(() => ({
|
||||||
|
shadowOpacity: interpolate(shadowExpand.value, [0, 1], [0.3, 0.5]),
|
||||||
|
shadowRadius: interpolate(shadowExpand.value, [0, 1], [24, 36]),
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: interpolate(shadowExpand.value, [0, 1], [12, 18]),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const overlayStyle = (direction: "left" | "right" | "up" | "down") =>
|
const overlayStyle = (direction: "left" | "right" | "up" | "down") =>
|
||||||
@@ -159,7 +196,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
|||||||
<>
|
<>
|
||||||
<View style={[styles.cardWrapper, { width: cardWidth }]}>
|
<View style={[styles.cardWrapper, { width: cardWidth }]}>
|
||||||
<GestureDetector gesture={gesture}>
|
<GestureDetector gesture={gesture}>
|
||||||
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.xl]}>
|
<Animated.View style={[styles.cardOuter, animatedStyle, cardShadowStyle, { shadowColor: '#000' }]}>
|
||||||
{useBlur ? (
|
{useBlur ? (
|
||||||
<BlurView
|
<BlurView
|
||||||
intensity={blur.medium}
|
intensity={blur.medium}
|
||||||
@@ -171,6 +208,8 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
|||||||
overlayStyle={overlayStyle}
|
overlayStyle={overlayStyle}
|
||||||
handleButtonPress={handleButtonPress}
|
handleButtonPress={handleButtonPress}
|
||||||
onFeedbackPress={handleFeedbackPress}
|
onFeedbackPress={handleFeedbackPress}
|
||||||
|
translateX={translateX}
|
||||||
|
translateY={translateY}
|
||||||
/>
|
/>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
) : (
|
) : (
|
||||||
@@ -180,9 +219,13 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
|||||||
overlayStyle={overlayStyle}
|
overlayStyle={overlayStyle}
|
||||||
handleButtonPress={handleButtonPress}
|
handleButtonPress={handleButtonPress}
|
||||||
onFeedbackPress={handleFeedbackPress}
|
onFeedbackPress={handleFeedbackPress}
|
||||||
|
translateX={translateX}
|
||||||
|
translateY={translateY}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{/* Inner border highlight for depth */}
|
||||||
|
<View style={styles.innerBorder} pointerEvents="none" />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
</View>
|
</View>
|
||||||
@@ -202,12 +245,36 @@ function CardContent({
|
|||||||
overlayStyle,
|
overlayStyle,
|
||||||
handleButtonPress,
|
handleButtonPress,
|
||||||
onFeedbackPress,
|
onFeedbackPress,
|
||||||
|
translateX,
|
||||||
|
translateY,
|
||||||
}: {
|
}: {
|
||||||
question: Props['question'];
|
question: Props['question'];
|
||||||
overlayStyle: (direction: "left" | "right" | "up" | "down") => any;
|
overlayStyle: (direction: "left" | "right" | "up" | "down") => any;
|
||||||
handleButtonPress: (direction: "left" | "right" | "up") => void;
|
handleButtonPress: (direction: "left" | "right" | "up") => void;
|
||||||
onFeedbackPress: () => void;
|
onFeedbackPress: () => void;
|
||||||
|
translateX: Animated.SharedValue<number>;
|
||||||
|
translateY: Animated.SharedValue<number>;
|
||||||
}) {
|
}) {
|
||||||
|
const isHighIntensity = question.intensity >= 3;
|
||||||
|
|
||||||
|
// Badge scale animation based on swipe progress
|
||||||
|
const badgeScaleStyle = (direction: "left" | "right" | "up" | "down") =>
|
||||||
|
useAnimatedStyle(() => {
|
||||||
|
let progress = 0;
|
||||||
|
if (direction === "right") {
|
||||||
|
progress = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1], Extrapolate.CLAMP);
|
||||||
|
} else if (direction === "left") {
|
||||||
|
progress = interpolate(translateX.value, [-SWIPE_THRESHOLD, 0], [1, 0], Extrapolate.CLAMP);
|
||||||
|
} else if (direction === "up") {
|
||||||
|
progress = interpolate(translateY.value, [-SWIPE_THRESHOLD, 0], [1, 0], Extrapolate.CLAMP);
|
||||||
|
} else {
|
||||||
|
progress = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 1], Extrapolate.CLAMP);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
transform: [{ scale: interpolate(progress, [0, 0.5, 1], [0.8, 1, 1.1]) }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Top highlight */}
|
{/* Top highlight */}
|
||||||
@@ -219,6 +286,24 @@ function CardContent({
|
|||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Left edge highlight */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={['rgba(255,255,255,0.08)', 'transparent']}
|
||||||
|
style={styles.edgeHighlightLeft}
|
||||||
|
start={{ x: 0, y: 0.5 }}
|
||||||
|
end={{ x: 1, y: 0.5 }}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right edge highlight */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={['transparent', 'rgba(255,255,255,0.08)']}
|
||||||
|
style={styles.edgeHighlightRight}
|
||||||
|
start={{ x: 0, y: 0.5 }}
|
||||||
|
end={{ x: 1, y: 0.5 }}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Feedback Button */}
|
{/* Feedback Button */}
|
||||||
<View style={styles.feedbackButton}>
|
<View style={styles.feedbackButton}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
@@ -229,31 +314,53 @@ function CardContent({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Overlays */}
|
{/* Overlays with gradient badges */}
|
||||||
<Animated.View style={[styles.overlay, overlayStyle("right")]} pointerEvents="none">
|
<Animated.View style={[styles.overlay, overlayStyle("right")]} pointerEvents="none">
|
||||||
<View style={[styles.overlayBadge, { backgroundColor: colors.success }]}>
|
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("right")]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.success as [string, string]}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
/>
|
||||||
<Text style={styles.overlayText}>YES</Text>
|
<Text style={styles.overlayText}>YES</Text>
|
||||||
</View>
|
</Animated.View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<Animated.View style={[styles.overlay, overlayStyle("left")]} pointerEvents="none">
|
<Animated.View style={[styles.overlay, overlayStyle("left")]} pointerEvents="none">
|
||||||
<View style={[styles.overlayBadge, { backgroundColor: colors.error }]}>
|
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("left")]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.error as [string, string]}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
/>
|
||||||
<Text style={styles.overlayText}>NO</Text>
|
<Text style={styles.overlayText}>NO</Text>
|
||||||
</View>
|
</Animated.View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<Animated.View style={[styles.overlay, overlayStyle("up")]} pointerEvents="none">
|
<Animated.View style={[styles.overlay, overlayStyle("up")]} pointerEvents="none">
|
||||||
<View style={[styles.overlayBadge, { backgroundColor: colors.warning }]}>
|
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("up")]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.warning as [string, string]}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
/>
|
||||||
<Text style={styles.overlayText}>MAYBE</Text>
|
<Text style={styles.overlayText}>MAYBE</Text>
|
||||||
</View>
|
</Animated.View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<Animated.View style={[styles.overlay, overlayStyle("down")]} pointerEvents="none">
|
<Animated.View style={[styles.overlay, overlayStyle("down")]} pointerEvents="none">
|
||||||
<View style={[styles.overlayBadge, { backgroundColor: colors.textTertiary }]}>
|
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("down")]}>
|
||||||
|
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.textTertiary }]} />
|
||||||
<Text style={styles.overlayText}>SKIP</Text>
|
<Text style={styles.overlayText}>SKIP</Text>
|
||||||
</View>
|
</Animated.View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<View style={styles.intensityContainer}>
|
<View style={[
|
||||||
|
styles.intensityContainer,
|
||||||
|
isHighIntensity && styles.intensityContainerHigh
|
||||||
|
]}>
|
||||||
{[...Array(question.intensity)].map((_, i) => (
|
{[...Array(question.intensity)].map((_, i) => (
|
||||||
<Ionicons key={i} name="flame" size={16} color={colors.primary} />
|
<Ionicons key={i} name="flame" size={16} color={colors.primary} />
|
||||||
))}
|
))}
|
||||||
@@ -299,26 +406,57 @@ function ActionButton({
|
|||||||
icon: string;
|
icon: string;
|
||||||
small?: boolean;
|
small?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const buttonScale = useSharedValue(1);
|
||||||
|
const buttonGlow = useSharedValue(0);
|
||||||
|
|
||||||
|
const handlePressIn = () => {
|
||||||
|
buttonScale.value = withSpring(0.92, animations.spring);
|
||||||
|
buttonGlow.value = withTiming(1, { duration: 150 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressOut = () => {
|
||||||
|
buttonScale.value = withSpring(1, animations.spring);
|
||||||
|
buttonGlow.value = withTiming(0, { duration: 200 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: buttonScale.value }],
|
||||||
|
shadowOpacity: interpolate(buttonGlow.value, [0, 1], [0.3, 0.6]),
|
||||||
|
shadowRadius: interpolate(buttonGlow.value, [0, 1], [8, 16]),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
|
<TouchableOpacity
|
||||||
<LinearGradient
|
onPress={onPress}
|
||||||
colors={buttonColors}
|
onPressIn={handlePressIn}
|
||||||
style={[
|
onPressOut={handlePressOut}
|
||||||
|
activeOpacity={1}
|
||||||
|
>
|
||||||
|
<Animated.View style={[
|
||||||
styles.actionButton,
|
styles.actionButton,
|
||||||
small && styles.actionButtonSmall,
|
small && styles.actionButtonSmall,
|
||||||
shadows.md,
|
buttonAnimatedStyle,
|
||||||
]}
|
{ shadowColor: buttonColors[0] }
|
||||||
|
]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={buttonColors}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
>
|
/>
|
||||||
<View style={styles.buttonHighlight} />
|
<LinearGradient
|
||||||
|
colors={['rgba(255,255,255,0.25)', 'rgba(255,255,255,0.05)']}
|
||||||
|
style={styles.buttonHighlight}
|
||||||
|
start={{ x: 0.5, y: 0 }}
|
||||||
|
end={{ x: 0.5, y: 1 }}
|
||||||
|
/>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={icon as any}
|
name={icon as any}
|
||||||
size={small ? 20 : 28}
|
size={small ? 20 : 28}
|
||||||
color={colors.text}
|
color={colors.text}
|
||||||
style={styles.buttonIcon}
|
style={styles.buttonIcon}
|
||||||
/>
|
/>
|
||||||
</LinearGradient>
|
</Animated.View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -358,6 +496,30 @@ const styles = StyleSheet.create({
|
|||||||
right: 0,
|
right: 0,
|
||||||
height: 80,
|
height: 80,
|
||||||
},
|
},
|
||||||
|
edgeHighlightLeft: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 60,
|
||||||
|
},
|
||||||
|
edgeHighlightRight: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 60,
|
||||||
|
},
|
||||||
|
innerBorder: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2,
|
||||||
|
left: 2,
|
||||||
|
right: 2,
|
||||||
|
bottom: 2,
|
||||||
|
borderRadius: radius.xxl - 2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.06)',
|
||||||
|
},
|
||||||
feedbackButton: {
|
feedbackButton: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: spacing.md,
|
top: spacing.md,
|
||||||
@@ -388,6 +550,11 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
gap: 2,
|
gap: 2,
|
||||||
},
|
},
|
||||||
|
intensityContainerHigh: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
...shadows.glow(colors.primaryGlow),
|
||||||
|
},
|
||||||
text: {
|
text: {
|
||||||
...typography.title2,
|
...typography.title2,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
@@ -414,6 +581,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
transform: [{ rotate: "-15deg" }],
|
transform: [{ rotate: "-15deg" }],
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
overlayText: {
|
overlayText: {
|
||||||
fontSize: 36,
|
fontSize: 36,
|
||||||
@@ -446,7 +614,8 @@ const styles = StyleSheet.create({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: "50%",
|
height: "50%",
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
borderTopLeftRadius: 32,
|
||||||
|
borderTopRightRadius: 32,
|
||||||
},
|
},
|
||||||
buttonIcon: {
|
buttonIcon: {
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.card,
|
styles.card,
|
||||||
|
webStyles.card as any,
|
||||||
{
|
{
|
||||||
width: cardWidth,
|
width: cardWidth,
|
||||||
transform: [
|
transform: [
|
||||||
@@ -206,7 +207,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
|||||||
<Ionicons key={i} name="flame" size={16} color="#e94560" />
|
<Ionicons key={i} name="flame" size={16} color="#e94560" />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.text, question.is_two_part ? styles.twoPartText : null]}>{question.text}</Text>
|
<Text style={[styles.text, question.partner_text ? styles.twoPartText : null]}>{question.text}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
@@ -218,6 +219,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
|||||||
hoveredButton === "no" && styles.buttonHovered,
|
hoveredButton === "no" && styles.buttonHovered,
|
||||||
]}
|
]}
|
||||||
onPress={() => onSwipe("left")}
|
onPress={() => onSwipe("left")}
|
||||||
|
// @ts-expect-error - web-only props
|
||||||
onMouseEnter={() => setHoveredButton("no")}
|
onMouseEnter={() => setHoveredButton("no")}
|
||||||
onMouseLeave={() => setHoveredButton(null)}
|
onMouseLeave={() => setHoveredButton(null)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
@@ -233,6 +235,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
|||||||
hoveredButton === "maybe" && styles.buttonHovered,
|
hoveredButton === "maybe" && styles.buttonHovered,
|
||||||
]}
|
]}
|
||||||
onPress={() => onSwipe("up")}
|
onPress={() => onSwipe("up")}
|
||||||
|
// @ts-expect-error - web-only props
|
||||||
onMouseEnter={() => setHoveredButton("maybe")}
|
onMouseEnter={() => setHoveredButton("maybe")}
|
||||||
onMouseLeave={() => setHoveredButton(null)}
|
onMouseLeave={() => setHoveredButton(null)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
@@ -248,6 +251,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
|||||||
hoveredButton === "yes" && styles.buttonHovered,
|
hoveredButton === "yes" && styles.buttonHovered,
|
||||||
]}
|
]}
|
||||||
onPress={() => onSwipe("right")}
|
onPress={() => onSwipe("right")}
|
||||||
|
// @ts-expect-error - web-only props
|
||||||
onMouseEnter={() => setHoveredButton("yes")}
|
onMouseEnter={() => setHoveredButton("yes")}
|
||||||
onMouseLeave={() => setHoveredButton(null)}
|
onMouseLeave={() => setHoveredButton(null)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
@@ -269,6 +273,12 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Separate web-only styles that TypeScript doesn't recognize
|
||||||
|
const webStyles = {
|
||||||
|
card: { cursor: "grab" as const },
|
||||||
|
actionButton: { transition: "transform 0.2s ease, box-shadow 0.2s ease" },
|
||||||
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -287,7 +297,6 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 4.65,
|
shadowRadius: 4.65,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
cursor: "grab",
|
|
||||||
},
|
},
|
||||||
feedbackButton: {
|
feedbackButton: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -402,7 +411,6 @@ const styles = StyleSheet.create({
|
|||||||
elevation: 8,
|
elevation: 8,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
transition: "transform 0.2s ease, box-shadow 0.2s ease",
|
|
||||||
},
|
},
|
||||||
noButton: {
|
noButton: {
|
||||||
backgroundColor: "#E74C3C",
|
backgroundColor: "#E74C3C",
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ export async function initAnalytics() {
|
|||||||
posthog = new PostHog(apiKey, {
|
posthog = new PostHog(apiKey, {
|
||||||
host,
|
host,
|
||||||
// Disable automatic capture in production for privacy
|
// Disable automatic capture in production for privacy
|
||||||
captureApplicationLifecycleEvents: true,
|
captureAppLifecycleEvents: true,
|
||||||
captureDeepLinks: true,
|
|
||||||
recordScreenViews: false, // We'll track manually for more control
|
|
||||||
});
|
});
|
||||||
|
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
|
|||||||
@@ -378,17 +378,23 @@ export type Database = {
|
|||||||
|
|
||||||
type PublicSchema = Database[Extract<keyof Database, "public">]
|
type PublicSchema = Database[Extract<keyof Database, "public">]
|
||||||
|
|
||||||
|
// Helper type to get schema keys excluding internal keys
|
||||||
|
type SchemaName = Exclude<keyof Database, "__InternalSupabase">
|
||||||
|
|
||||||
|
// Helper to safely get schema
|
||||||
|
type GetSchema<S extends SchemaName> = Database[S]
|
||||||
|
|
||||||
export type Tables<
|
export type Tables<
|
||||||
PublicTableNameOrOptions extends
|
PublicTableNameOrOptions extends
|
||||||
| keyof (PublicSchema["Tables"] & PublicSchema["Views"])
|
| keyof (PublicSchema["Tables"] & PublicSchema["Views"])
|
||||||
| { schema: keyof Database },
|
| { schema: SchemaName },
|
||||||
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
TableName extends PublicTableNameOrOptions extends { schema: SchemaName }
|
||||||
? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
|
? keyof (GetSchema<PublicTableNameOrOptions["schema"]>["Tables"] &
|
||||||
Database[PublicTableNameOrOptions["schema"]]["Views"])
|
GetSchema<PublicTableNameOrOptions["schema"]>["Views"])
|
||||||
: never = never,
|
: never = never,
|
||||||
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
> = PublicTableNameOrOptions extends { schema: SchemaName }
|
||||||
? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
|
? (GetSchema<PublicTableNameOrOptions["schema"]>["Tables"] &
|
||||||
Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
GetSchema<PublicTableNameOrOptions["schema"]>["Views"])[TableName] extends {
|
||||||
Row: infer R
|
Row: infer R
|
||||||
}
|
}
|
||||||
? R
|
? R
|
||||||
@@ -406,12 +412,12 @@ export type Tables<
|
|||||||
export type TablesInsert<
|
export type TablesInsert<
|
||||||
PublicTableNameOrOptions extends
|
PublicTableNameOrOptions extends
|
||||||
| keyof PublicSchema["Tables"]
|
| keyof PublicSchema["Tables"]
|
||||||
| { schema: keyof Database },
|
| { schema: SchemaName },
|
||||||
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
TableName extends PublicTableNameOrOptions extends { schema: SchemaName }
|
||||||
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
|
? keyof GetSchema<PublicTableNameOrOptions["schema"]>["Tables"]
|
||||||
: never = never,
|
: never = never,
|
||||||
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
> = PublicTableNameOrOptions extends { schema: SchemaName }
|
||||||
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
? GetSchema<PublicTableNameOrOptions["schema"]>["Tables"][TableName] extends {
|
||||||
Insert: infer I
|
Insert: infer I
|
||||||
}
|
}
|
||||||
? I
|
? I
|
||||||
@@ -427,12 +433,12 @@ export type TablesInsert<
|
|||||||
export type TablesUpdate<
|
export type TablesUpdate<
|
||||||
PublicTableNameOrOptions extends
|
PublicTableNameOrOptions extends
|
||||||
| keyof PublicSchema["Tables"]
|
| keyof PublicSchema["Tables"]
|
||||||
| { schema: keyof Database },
|
| { schema: SchemaName },
|
||||||
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
TableName extends PublicTableNameOrOptions extends { schema: SchemaName }
|
||||||
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
|
? keyof GetSchema<PublicTableNameOrOptions["schema"]>["Tables"]
|
||||||
: never = never,
|
: never = never,
|
||||||
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
> = PublicTableNameOrOptions extends { schema: SchemaName }
|
||||||
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
? GetSchema<PublicTableNameOrOptions["schema"]>["Tables"][TableName] extends {
|
||||||
Update: infer U
|
Update: infer U
|
||||||
}
|
}
|
||||||
? U
|
? U
|
||||||
@@ -448,12 +454,12 @@ export type TablesUpdate<
|
|||||||
export type Enums<
|
export type Enums<
|
||||||
PublicEnumNameOrOptions extends
|
PublicEnumNameOrOptions extends
|
||||||
| keyof PublicSchema["Enums"]
|
| keyof PublicSchema["Enums"]
|
||||||
| { schema: keyof Database },
|
| { schema: SchemaName },
|
||||||
EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
|
EnumName extends PublicEnumNameOrOptions extends { schema: SchemaName }
|
||||||
? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
|
? keyof GetSchema<PublicEnumNameOrOptions["schema"]>["Enums"]
|
||||||
: never = never,
|
: never = never,
|
||||||
> = PublicEnumNameOrOptions extends { schema: keyof Database }
|
> = PublicEnumNameOrOptions extends { schema: SchemaName }
|
||||||
? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
? GetSchema<PublicEnumNameOrOptions["schema"]>["Enums"][EnumName]
|
||||||
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
|
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
|
||||||
? PublicSchema["Enums"][PublicEnumNameOrOptions]
|
? PublicSchema["Enums"][PublicEnumNameOrOptions]
|
||||||
: never
|
: never
|
||||||
@@ -461,14 +467,14 @@ export type Enums<
|
|||||||
export type CompositeTypes<
|
export type CompositeTypes<
|
||||||
PublicCompositeTypeNameOrOptions extends
|
PublicCompositeTypeNameOrOptions extends
|
||||||
| keyof PublicSchema["CompositeTypes"]
|
| keyof PublicSchema["CompositeTypes"]
|
||||||
| { schema: keyof Database },
|
| { schema: SchemaName },
|
||||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||||
schema: keyof Database
|
schema: SchemaName
|
||||||
}
|
}
|
||||||
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
? keyof GetSchema<PublicCompositeTypeNameOrOptions["schema"]>["CompositeTypes"]
|
||||||
: never = never,
|
: never = never,
|
||||||
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
|
> = PublicCompositeTypeNameOrOptions extends { schema: SchemaName }
|
||||||
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
? GetSchema<PublicCompositeTypeNameOrOptions["schema"]>["CompositeTypes"][CompositeTypeName]
|
||||||
: PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"]
|
: PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"]
|
||||||
? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
||||||
: never
|
: never
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"lib": [
|
"lib": [
|
||||||
"ES2020"
|
"ES2020",
|
||||||
|
"DOM"
|
||||||
],
|
],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"@expo/ngrok": "^4.1.0",
|
"@expo/ngrok": "^4.1.0",
|
||||||
"@expo/vector-icons": "~14.0.4",
|
"@expo/vector-icons": "~14.0.4",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
|
"@react-native-google-signin/google-signin": "^16.1.1",
|
||||||
"@react-navigation/bottom-tabs": "^7.0.0",
|
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||||
"@react-navigation/native": "^7.0.0",
|
"@react-navigation/native": "^7.0.0",
|
||||||
"@react-navigation/native-stack": "^7.0.0",
|
"@react-navigation/native-stack": "^7.0.0",
|
||||||
@@ -5891,6 +5892,22 @@
|
|||||||
"react-native": "^0.0.0-0 || >=0.60 <1.0"
|
"react-native": "^0.0.0-0 || >=0.60 <1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-native-google-signin/google-signin": {
|
||||||
|
"version": "16.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-native-google-signin/google-signin/-/google-signin-16.1.1.tgz",
|
||||||
|
"integrity": "sha512-lcHBnZ7uvCJiWtGooKOklo/4okqszWvJ0BatW1UaIe+ynmpVpp1lyJkvv1Mj08d39k4soaWuhZVNKjD/RFL34Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": ">=52.0.40",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"expo": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-native/assets-registry": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.76.9",
|
"version": "0.76.9",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz",
|
||||||
|
|||||||