Compare commits

..

1 Commits

Author SHA1 Message Date
AppCakes 068b82acfc build: 9b712b37-afab-4b8f-a695-2ceae4868921 2026-06-11 07:51:59 +00:00
94 changed files with 2988 additions and 7417 deletions
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@ android {
namespace = "io.ionic.starter" namespace = "io.ionic.starter"
compileSdk = rootProject.ext.compileSdkVersion compileSdk = rootProject.ext.compileSdkVersion
defaultConfig { defaultConfig {
applicationId "com.inspot.app" applicationId "com.qoutelink.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
+1 -12
View File
@@ -1,11 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -30,15 +25,9 @@
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.inspot.app" /> <data android:scheme="com.qoutelink.app" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.inspot.app" />
</intent-filter>
</activity> </activity>
<meta-data <meta-data
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 742 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 742 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#FFFFFF</color> <color name="ic_launcher_background">#FFFFFF</color>
</resources> </resources>
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<resources> <resources>
<string name="app_name">InSpot</string> <string name="app_name">Qoutelink</string>
<string name="title_activity_main">InSpot</string> <string name="title_activity_main">Qoutelink</string>
<string name="package_name">io.ionic.starter</string> <string name="package_name">io.ionic.starter</string>
<string name="custom_url_scheme">io.ionic.starter</string> <string name="custom_url_scheme">io.ionic.starter</string>
</resources> </resources>
+2 -2
View File
@@ -1,8 +1,8 @@
import type { CapacitorConfig } from '@capacitor/cli'; import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = { const config: CapacitorConfig = {
appId: 'com.inspot.app', appId: 'com.qoutelink.app',
appName: 'InSpot', appName: 'Qoutelink',
webDir: 'dist', webDir: 'dist',
plugins: { plugins: {
FirebaseAuthentication: { FirebaseAuthentication: {
+2826 -15
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -308,7 +308,7 @@
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.inspot.app; PRODUCT_BUNDLE_IDENTIFIER = com.qoutelink.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -330,7 +330,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.inspot.app; PRODUCT_BUNDLE_IDENTIFIER = com.qoutelink.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
+1 -18
View File
@@ -1,7 +1,5 @@
import UIKit import UIKit
import Capacitor import Capacitor
import FirebaseCore
import FirebaseMessaging
@UIApplicationMain @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
@@ -9,25 +7,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow? var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FirebaseApp.configure() // Override point for customization after application launch.
return true return true
} }
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
Messaging.messaging().token { token, error in
if let error = error {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
} else if let token = token {
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: token)
}
}
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
}
func applicationWillResignActive(_ application: UIApplication) { func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
@@ -1,122 +1,14 @@
{ {
"images": [ "images" : [
{ {
"idiom": "iphone", "filename" : "AppIcon-512@2x.png",
"scale": "1x", "idiom" : "universal",
"size": "20x20", "platform" : "ios",
"filename": "icon-20.png" "size" : "1024x1024"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "20x20",
"filename": "icon-20.png"
},
{
"idiom": "iphone",
"scale": "1x",
"size": "29x29",
"filename": "icon-29.png"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "29x29",
"filename": "icon-29.png"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "40x40",
"filename": "icon-40.png"
},
{
"idiom": "iphone",
"scale": "2x",
"size": "20x20",
"filename": "icon-40.png"
},
{
"idiom": "iphone",
"scale": "2x",
"size": "29x29",
"filename": "icon-58.png"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "29x29",
"filename": "icon-58.png"
},
{
"idiom": "iphone",
"scale": "3x",
"size": "20x20",
"filename": "icon-60.png"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "76x76",
"filename": "icon-76.png"
},
{
"idiom": "iphone",
"scale": "2x",
"size": "40x40",
"filename": "icon-80.png"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "40x40",
"filename": "icon-80.png"
},
{
"idiom": "iphone",
"scale": "3x",
"size": "29x29",
"filename": "icon-87.png"
},
{
"idiom": "iphone",
"scale": "2x",
"size": "60x60",
"filename": "icon-120.png"
},
{
"idiom": "iphone",
"scale": "3x",
"size": "40x40",
"filename": "icon-120.png"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "76x76",
"filename": "icon-152.png"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "83.5x83.5",
"filename": "icon-167.png"
},
{
"idiom": "iphone",
"scale": "3x",
"size": "60x60",
"filename": "icon-180.png"
},
{
"idiom": "ios-marketing",
"scale": "1x",
"size": "1024x1024",
"filename": "icon-1024.png"
} }
], ],
"info": { "info" : {
"author": "appsuite", "author" : "xcode",
"version": 1 "version" : 1
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 979 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

+3 -22
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>InSpot</string> <string>Qoutelink</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -51,31 +51,12 @@
<array> <array>
<dict> <dict>
<key>CFBundleURLName</key> <key>CFBundleURLName</key>
<string>com.inspot.app</string> <string>com.qoutelink.app</string>
<key>CFBundleURLSchemes</key> <key>CFBundleURLSchemes</key>
<array> <array>
<string>com.inspot.app</string> <string>com.qoutelink.app</string>
</array> </array>
</dict> </dict>
</array> </array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.inspot.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.inspot.app</string>
</array>
</dict>
</array>
<key>NSCameraUsageDescription</key>
<string>InSpot needs camera access to let you capture and share reels of local spots.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>InSpot needs photo library access to let you upload profile photos and share reels from your gallery.</string>
<key>NSMicrophoneUsageDescription</key>
<string>InSpot needs microphone access to record sound for your reels.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>InSpot needs your location to discover nearby spots and social connections.</string>
</dict> </dict>
</plist> </plist>
+1 -2
View File
@@ -24,11 +24,10 @@
"@ionic/pwa-elements": "^3.0.0", "@ionic/pwa-elements": "^3.0.0",
"@ionic/react": "8.8.7", "@ionic/react": "8.8.7",
"@ionic/react-router": "8.8.7", "@ionic/react-router": "8.8.7",
"@supabase/supabase-js": "^2.108.1", "@supabase/supabase-js": "^2.0.0",
"firebase": "^11.0.0", "firebase": "^11.0.0",
"ionicons": "^7.4.0", "ionicons": "^7.4.0",
"katex": "^0.16.0", "katex": "^0.16.0",
"lottie-react": "^2.4.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.54.0", "react-hook-form": "^7.54.0",
+22 -143
View File
@@ -1,152 +1,31 @@
import React from 'react'; import React from "react";
import { Redirect, Route } from 'react-router-dom'; import { Redirect, Route } from "react-router-dom";
import { import { IonApp, IonRouterOutlet } from "@ionic/react";
IonApp, import { IonReactRouter } from "@ionic/react-router";
IonIcon,
IonLabel,
IonRouterOutlet,
IonTabBar,
IonTabButton,
IonTabs,
} from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router';
import {
flameOutline,
locationOutline,
chatbubblesOutline,
personOutline,
} from 'ionicons/icons';
/* Core CSS required for Ionic components to work properly */ import "@ionic/react/css/core.css";
import '@ionic/react/css/core.css'; import "@ionic/react/css/normalize.css";
import '@ionic/react/css/normalize.css'; import "@ionic/react/css/structure.css";
import '@ionic/react/css/structure.css'; import "@ionic/react/css/typography.css";
import '@ionic/react/css/typography.css'; import "@ionic/react/css/padding.css";
import "@ionic/react/css/float-elements.css";
import "@ionic/react/css/text-alignment.css";
import "@ionic/react/css/text-transformation.css";
import "@ionic/react/css/flex-utils.css";
import "@ionic/react/css/display.css";
/* Optional CSS utils that can be commented out */ import "./theme/variables.css";
import '@ionic/react/css/padding.css';
import '@ionic/react/css/float-elements.css';
import '@ionic/react/css/text-alignment.css';
import '@ionic/react/css/text-transformation.css';
import '@ionic/react/css/flex-utils.css';
import '@ionic/react/css/display.css';
/* Theme variables */ import Home from "./pages/Home";
import './theme/variables.css';
/* Pages */
import AuthPage from './pages/AuthPage';
import VerifyEmailPage from './pages/VerifyEmailPage';
import ForgotPasswordPage from './pages/ForgotPasswordPage';
import NewPasswordPage from './pages/NewPasswordPage';
import OnboardingProfilePage from './pages/OnboardingProfilePage';
import IcebreakerPage from './pages/IcebreakerPage';
import HangoutPage from './pages/HangoutPage';
import ReelsPage from './pages/ReelsPage';
import CameraPage from './pages/CameraPage';
import ChatsPage from './pages/ChatsPage';
import ChatThreadPage from './pages/ChatThreadPage';
import ProfilePage from './pages/ProfilePage';
import EditProfilePage from './pages/EditProfilePage';
import NotificationsPage from './pages/NotificationsPage';
import PremiumPage from './pages/PremiumPage';
/* Contexts & Components */
import { AuthProvider } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import { isPreviewMode } from './utils/previewMode';
const App: React.FC = () => ( const App: React.FC = () => (
<IonApp> <IonApp>
<AuthProvider> <IonReactRouter>
<IonReactRouter> <IonRouterOutlet>
<IonRouterOutlet> <Route path="/home" component={Home} exact />
{/* Auth Stack */} <Redirect exact from="/" to="/home" />
<Route path="/auth" component={AuthPage} exact /> </IonRouterOutlet>
<Route path="/verify-email" component={VerifyEmailPage} exact /> </IonReactRouter>
<Route path="/forgot-password" component={ForgotPasswordPage} exact />
<Route path="/new-password" component={NewPasswordPage} exact />
<ProtectedRoute
path="/onboarding/profile"
component={OnboardingProfilePage}
exact
/>
{/* Detailed Stack (No Tabs) */}
<ProtectedRoute path="/reels/:placeId" component={ReelsPage} exact />
<ProtectedRoute
path="/chat/:conversationId"
component={ChatThreadPage}
exact
/>
<ProtectedRoute
path="/profile/edit"
component={EditProfilePage}
exact
/>
<ProtectedRoute path="/premium" component={PremiumPage} exact />
<ProtectedRoute
path="/notifications"
component={NotificationsPage}
exact
/>
<ProtectedRoute path="/camera" component={CameraPage} exact />
{/* Main Tabs */}
<Route
path={['/icebreaker', '/hangout', '/chats', '/profile']}
render={() => (
<IonTabs>
<IonRouterOutlet>
<ProtectedRoute
path="/icebreaker"
component={IcebreakerPage}
exact
/>
<ProtectedRoute
path="/hangout"
component={HangoutPage}
exact
/>
<ProtectedRoute path="/chats" component={ChatsPage} exact />
<ProtectedRoute
path="/profile"
component={ProfilePage}
exact
/>
</IonRouterOutlet>
<IonTabBar slot="bottom">
<IonTabButton tab="icebreaker" href="/icebreaker">
<IonIcon icon={flameOutline} />
<IonLabel>Icebreaker</IonLabel>
</IonTabButton>
<IonTabButton tab="hangout" href="/hangout">
<IonIcon icon={locationOutline} />
<IonLabel>Hangout</IonLabel>
</IonTabButton>
<IonTabButton tab="chats" href="/chats">
<IonIcon icon={chatbubblesOutline} />
<IonLabel>Chats</IonLabel>
</IonTabButton>
<IonTabButton tab="profile" href="/profile">
<IonIcon icon={personOutline} />
<IonLabel>Profile</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
)}
/>
<Route
exact
path="/"
render={() => (
<Redirect to={isPreviewMode() ? '/icebreaker' : '/auth'} />
)}
/>
</IonRouterOutlet>
</IonReactRouter>
</AuthProvider>
</IonApp> </IonApp>
); );
View File
Binary file not shown.
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-1
View File
@@ -1 +0,0 @@
{"nm":"Main Scene","ddd":0,"h":1920,"w":1920,"meta":{"g":"@lottiefiles/creator 1.37.1"},"layers":[{"ty":4,"nm":"Layer 1 Outlines 3","sr":1,"st":0,"op":420,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[523.115,1227.748,0],"ix":1},"s":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[43,36],"t":0},{"s":[157,192.00000000000003],"t":25}],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[960,1277.507],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[30],"t":12},{"s":[0],"t":25}],"ix":11}},"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 3","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[-265.484,0],[0,-135.236],[265.484,0],[0,135.235]],"o":[[265.484,0],[0,135.235],[-265.484,0],[0,-135.236]],"v":[[0,-244.865],[480.701,0.001],[0,244.866],[-480.701,0.001]]},"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[20],"t":0},{"s":[50],"t":25}],"ix":5},"c":{"a":0,"k":[0.9961,0.2353,0.4471],"ix":3}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[523.115,1227.748],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":1},{"ty":4,"nm":"Layer 1 Outlines 2","sr":1,"st":0,"op":420,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[523.115,382.842,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[960,413.401],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[960,433.401],"t":12},{"s":[960,413.401],"t":25}],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":6,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[106.139,0],[0,106.14],[-106.139,0],[0,-106.141]],"o":[[-106.141,0],[0,-106.141],[106.139,0],[0,106.141]],"v":[[0,44.966],[-192.49,-147.526],[0,-340.016],[192.488,-147.524]]},"ix":2}},{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 2","ix":2,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[210.961,0],[0,-210.961],[-14.577,-16.234],[-13.668,15.223],[0,261.809]],"o":[[-210.963,0],[0,261.809],[13.693,15.25],[14.577,-16.234],[-0.004,-210.961]],"v":[[0,-530.117],[-382.593,-147.526],[-25.633,514.867],[25.633,514.867],[382.593,-147.526]]},"ix":2}},{"ty":"mm","bm":0,"hd":false,"mn":"ADBE Vector Filter - Merge","nm":"Merge Paths 1","mm":1},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.9961,0.2353,0.4471],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[523.115,530.367],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":2},{"ty":4,"nm":"Layer 1 Outlines","sr":1,"st":0,"op":420,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[523.115,1307.748],"ix":1},"s":{"a":0,"k":[100,123],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[960,1278.307],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":80,"ix":11}},"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 2","ix":1,"cix":2,"np":4,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,-46.929],[123.812,0],[0,46.929],[-123.812,0]],"o":[[0,46.929],[-123.812,0],[0,-46.929],[123.812,0]],"v":[[224.182,0],[0,84.972],[-224.182,0],[0,-84.972]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.9961,0.2353,0.4471],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[523.115,1307.748],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":3}],"v":"5.7.0","fr":30,"op":30,"ip":0,"assets":[]}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

-70
View File
@@ -1,70 +0,0 @@
import React from 'react';
import { getStorageUrl, getInitials, getInitialsColor } from '../utils/storage';
interface AvatarProps {
src?: string | null;
name?: string | null;
size?: number;
bucket?: string;
className?: string;
style?: React.CSSProperties;
}
const Avatar: React.FC<AvatarProps> = ({
src,
name,
size = 48,
bucket = 'avatars',
className = '',
style = {},
}) => {
const url =
src?.startsWith('/src/assets/') || src?.startsWith('/assets/')
? src
: getStorageUrl(bucket, src);
const containerStyle: React.CSSProperties = {
width: size,
height: size,
borderRadius: '50%',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
background: url ? 'transparent' : getInitialsColor(name || null),
...style,
};
if (url) {
return (
<div className={className} style={containerStyle}>
<img
src={url}
alt={name || 'Avatar'}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
);
}
return (
<div className={className} style={containerStyle}>
<span
style={{
color: '#ffffff',
fontSize: size * 0.4,
fontWeight: 700,
letterSpacing: '-0.02em',
}}
>
{getInitials(name || null)}
</span>
</div>
);
};
export default Avatar;
-84
View File
@@ -1,84 +0,0 @@
import React from 'react';
import { IonButton, IonIcon } from '@ionic/react';
interface EmptyStateProps {
icon: string;
title: string;
message: string;
ctaText?: string;
onCtaClick?: () => void;
style?: React.CSSProperties;
}
const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
message,
ctaText,
onCtaClick,
style = {},
}) => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 20px',
textAlign: 'center',
minHeight: '300px',
...style,
}}
>
<div
style={{
width: '80px',
height: '80px',
borderRadius: '24px',
background: 'var(--color-surface-raised)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '20px',
}}
>
<IonIcon
icon={icon}
style={{ fontSize: '40px', color: 'var(--ion-color-primary)' }}
/>
</div>
<h3
style={{
fontSize: '20px',
fontWeight: 700,
margin: '0 0 8px',
color: 'var(--color-text-primary)',
}}
>
{title}
</h3>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: '0 0 24px',
lineHeight: 1.5,
}}
>
{message}
</p>
{ctaText && onCtaClick && (
<IonButton
expand="block"
onClick={onCtaClick}
style={{ '--border-radius': 'var(--radius-pill)', minWidth: '200px' }}
>
{ctaText}
</IonButton>
)}
</div>
);
};
export default EmptyState;
-97
View File
@@ -1,97 +0,0 @@
import React, { useRef, useEffect } from 'react';
interface OtpInputProps {
value: string;
onChange: (value: string) => void;
length?: number;
disabled?: boolean;
}
const OtpInput: React.FC<OtpInputProps> = ({
value,
onChange,
length = 6,
disabled = false,
}) => {
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement>,
index: number
) => {
const val = e.target.value;
if (!val) return;
const newOtp = value.split('');
newOtp[index] = val.slice(-1);
const combined = newOtp.join('');
onChange(combined);
if (val && index < length - 1) {
inputRefs.current[index + 1]?.focus();
}
};
const handleKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>,
index: number
) => {
if (e.key === 'Backspace' && !value[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const pastedData = e.clipboardData.getData('text').slice(0, length);
if (/^\d+$/.test(pastedData)) {
onChange(pastedData);
inputRefs.current[Math.min(pastedData.length, length - 1)]?.focus();
}
};
return (
<div
style={{
display: 'flex',
gap: '8px',
justifyContent: 'center',
margin: '24px 0',
}}
>
{Array.from({ length }).map((_, i) => (
<input
key={i}
ref={(el) => {
inputRefs.current[i] = el;
}}
type="text"
inputMode="numeric"
pattern="\d*"
maxLength={1}
value={value[i] || ''}
onChange={(e) => handleChange(e, i)}
onKeyDown={(e) => handleKeyDown(e, i)}
onPaste={handlePaste}
disabled={disabled}
style={{
width: '44px',
height: '52px',
textAlign: 'center',
fontSize: '22px',
fontWeight: 700,
borderRadius: '12px',
border: '2px solid transparent',
background: 'var(--color-surface)',
color: 'var(--color-text-primary)',
outline: 'none',
transition: 'border-color 0.2s',
...(value[i] ? { borderColor: 'var(--ion-color-primary)' } : {}),
}}
/>
))}
</div>
);
};
export default OtpInput;
-27
View File
@@ -1,27 +0,0 @@
import React from 'react';
import { Route, Redirect, RouteProps } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { isPreviewMode } from '../utils/previewMode';
interface ProtectedRouteProps extends RouteProps {
component: React.ComponentType<any>;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
component: Component,
...rest
}) => {
const { user } = useAuth();
const previewMode = isPreviewMode();
return (
<Route
{...rest}
render={(props) =>
user || previewMode ? <Component {...props} /> : <Redirect to="/auth" />
}
/>
);
};
export default ProtectedRoute;
-68
View File
@@ -1,68 +0,0 @@
import React from 'react';
import { IonSearchbar, IonButton, IonIcon } from '@ionic/react';
import { optionsOutline } from 'ionicons/icons';
interface SearchRowProps {
value: string;
onChange: (e: any) => void;
onFilterClick?: () => void;
placeholder?: string;
}
const SearchRow: React.FC<SearchRowProps> = ({
value,
onChange,
onFilterClick,
placeholder = 'Search...',
}) => {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
margin: '0 20px 14px',
}}
>
<IonSearchbar
value={value}
onIonInput={onChange}
placeholder={placeholder}
style={{
padding: 0,
'--border-radius': 'var(--radius-pill)',
'--background': 'rgba(255,255,255,0.06)',
'--color': 'var(--color-text-primary)',
'--placeholder-color': 'rgba(255,255,255,0.45)',
'--icon-color': 'rgba(255,255,255,0.75)',
'--clear-button-color': 'rgba(255,255,255,0.55)',
}}
/>
{onFilterClick && (
<IonButton
onClick={onFilterClick}
style={{
'--background': 'rgba(255,255,255,0.06)',
'--color': 'var(--color-text-primary)',
'--border-radius': '12px',
width: '44px',
height: '44px',
margin: 0,
flexShrink: 0,
'--padding-start': 0,
'--padding-end': 0,
'--box-shadow': 'none',
}}
>
<IonIcon
icon={optionsOutline}
slot="icon-only"
style={{ fontSize: '20px' }}
/>
</IonButton>
)}
</div>
);
};
export default SearchRow;
-107
View File
@@ -1,107 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { User, Session } from '@supabase/supabase-js';
import { supabase } from '../supabase';
import { Database } from '../database.types';
import { IonSpinner } from '@ionic/react';
type Profile = Database['public']['Tables']['profiles']['Row'];
interface AuthContextType {
user: User | null;
session: Session | null;
profile: Profile | null;
loading: boolean;
refreshProfile: () => Promise<void>;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true);
const refreshProfile = async (userId?: string) => {
const id = userId || user?.id;
if (!id) return;
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', id)
.single();
if (data) {
setProfile(data);
} else if (error && error.code !== 'PGRST116') {
console.error('Error fetching profile:', error);
}
};
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
if (session?.user) {
refreshProfile(session.user.id).finally(() => setLoading(false));
} else {
setLoading(false);
}
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
setUser(session?.user ?? null);
if (session?.user) {
refreshProfile(session.user.id);
} else {
setProfile(null);
}
setLoading(false);
});
return () => subscription.unsubscribe();
}, []);
const signOut = async () => {
await supabase.auth.signOut();
};
if (loading) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
background: 'var(--color-bg)',
}}
>
<IonSpinner name="crescent" color="primary" />
</div>
);
}
return (
<AuthContext.Provider
value={{ user, session, profile, loading, refreshProfile, signOut }}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
-758
View File
@@ -1,758 +0,0 @@
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.5"
}
public: {
Tables: {
check_ins: {
Row: {
checked_in_at: string | null
expires_at: string
id: string
place_id: string
user_id: string
}
Insert: {
checked_in_at?: string | null
expires_at: string
id?: string
place_id: string
user_id: string
}
Update: {
checked_in_at?: string | null
expires_at?: string
id?: string
place_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "check_ins_place_id_fkey"
columns: ["place_id"]
isOneToOne: false
referencedRelation: "places"
referencedColumns: ["id"]
},
{
foreignKeyName: "check_ins_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
conversations: {
Row: {
created_at: string | null
id: string
match_id: string
updated_at: string | null
}
Insert: {
created_at?: string | null
id?: string
match_id: string
updated_at?: string | null
}
Update: {
created_at?: string | null
id?: string
match_id?: string
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "conversations_match_id_fkey"
columns: ["match_id"]
isOneToOne: true
referencedRelation: "matches"
referencedColumns: ["id"]
},
]
}
discovery_preferences: {
Row: {
goals: string[]
max_age: number
max_distance_km: number
min_age: number
updated_at: string | null
user_id: string
}
Insert: {
goals?: string[]
max_age?: number
max_distance_km?: number
min_age?: number
updated_at?: string | null
user_id: string
}
Update: {
goals?: string[]
max_age?: number
max_distance_km?: number
min_age?: number
updated_at?: string | null
user_id?: string
}
Relationships: [
{
foreignKeyName: "discovery_preferences_user_id_fkey"
columns: ["user_id"]
isOneToOne: true
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
matches: {
Row: {
created_at: string | null
id: string
last_message_at: string | null
user_a: string
user_b: string
}
Insert: {
created_at?: string | null
id?: string
last_message_at?: string | null
user_a: string
user_b: string
}
Update: {
created_at?: string | null
id?: string
last_message_at?: string | null
user_a?: string
user_b?: string
}
Relationships: [
{
foreignKeyName: "matches_user_a_fkey"
columns: ["user_a"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "matches_user_b_fkey"
columns: ["user_b"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
messages: {
Row: {
body: string
conversation_id: string
created_at: string | null
id: string
read_at: string | null
sender_id: string
}
Insert: {
body: string
conversation_id: string
created_at?: string | null
id?: string
read_at?: string | null
sender_id: string
}
Update: {
body?: string
conversation_id?: string
created_at?: string | null
id?: string
read_at?: string | null
sender_id?: string
}
Relationships: [
{
foreignKeyName: "messages_conversation_id_fkey"
columns: ["conversation_id"]
isOneToOne: false
referencedRelation: "conversations"
referencedColumns: ["id"]
},
{
foreignKeyName: "messages_sender_id_fkey"
columns: ["sender_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
notifications: {
Row: {
body: string
created_at: string | null
deep_link: string | null
id: string
read_at: string | null
title: string
type: string
user_id: string
}
Insert: {
body: string
created_at?: string | null
deep_link?: string | null
id?: string
read_at?: string | null
title: string
type: string
user_id: string
}
Update: {
body?: string
created_at?: string | null
deep_link?: string | null
id?: string
read_at?: string | null
title?: string
type?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "notifications_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
payments: {
Row: {
amount: number
created_at: string | null
currency: string
gateway: string
id: string
metadata: Json
reference: string
status: string
updated_at: string | null
user_id: string
}
Insert: {
amount: number
created_at?: string | null
currency?: string
gateway?: string
id?: string
metadata?: Json
reference: string
status?: string
updated_at?: string | null
user_id: string
}
Update: {
amount?: number
created_at?: string | null
currency?: string
gateway?: string
id?: string
metadata?: Json
reference?: string
status?: string
updated_at?: string | null
user_id?: string
}
Relationships: []
}
places: {
Row: {
address: string | null
busyness: string
category: string
created_at: string | null
created_by: string | null
description: string | null
google_place_id: string | null
id: string
image_path: string | null
latitude: number
longitude: number
name: string
rating: number
review_count: number
updated_at: string | null
}
Insert: {
address?: string | null
busyness?: string
category?: string
created_at?: string | null
created_by?: string | null
description?: string | null
google_place_id?: string | null
id?: string
image_path?: string | null
latitude: number
longitude: number
name: string
rating?: number
review_count?: number
updated_at?: string | null
}
Update: {
address?: string | null
busyness?: string
category?: string
created_at?: string | null
created_by?: string | null
description?: string | null
google_place_id?: string | null
id?: string
image_path?: string | null
latitude?: number
longitude?: number
name?: string
rating?: number
review_count?: number
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "places_created_by_fkey"
columns: ["created_by"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
profile_photos: {
Row: {
created_at: string | null
id: string
is_primary: boolean
position: number
storage_path: string
user_id: string
}
Insert: {
created_at?: string | null
id?: string
is_primary?: boolean
position?: number
storage_path: string
user_id: string
}
Update: {
created_at?: string | null
id?: string
is_primary?: boolean
position?: number
storage_path?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "profile_photos_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
profiles: {
Row: {
avatar_path: string | null
bio: string | null
birth_date: string | null
city: string | null
created_at: string | null
fcm_token: string | null
full_name: string
gender: string | null
id: string
interests: string[]
latitude: number | null
longitude: number | null
onboarding_complete: boolean
relationship_goal: string | null
updated_at: string | null
}
Insert: {
avatar_path?: string | null
bio?: string | null
birth_date?: string | null
city?: string | null
created_at?: string | null
fcm_token?: string | null
full_name?: string
gender?: string | null
id: string
interests?: string[]
latitude?: number | null
longitude?: number | null
onboarding_complete?: boolean
relationship_goal?: string | null
updated_at?: string | null
}
Update: {
avatar_path?: string | null
bio?: string | null
birth_date?: string | null
city?: string | null
created_at?: string | null
fcm_token?: string | null
full_name?: string
gender?: string | null
id?: string
interests?: string[]
latitude?: number | null
longitude?: number | null
onboarding_complete?: boolean
relationship_goal?: string | null
updated_at?: string | null
}
Relationships: []
}
reel_comments: {
Row: {
body: string
created_at: string | null
id: string
reel_id: string
user_id: string
}
Insert: {
body: string
created_at?: string | null
id?: string
reel_id: string
user_id: string
}
Update: {
body?: string
created_at?: string | null
id?: string
reel_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "reel_comments_reel_id_fkey"
columns: ["reel_id"]
isOneToOne: false
referencedRelation: "reels"
referencedColumns: ["id"]
},
{
foreignKeyName: "reel_comments_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
reel_likes: {
Row: {
created_at: string | null
reel_id: string
user_id: string
}
Insert: {
created_at?: string | null
reel_id: string
user_id: string
}
Update: {
created_at?: string | null
reel_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "reel_likes_reel_id_fkey"
columns: ["reel_id"]
isOneToOne: false
referencedRelation: "reels"
referencedColumns: ["id"]
},
{
foreignKeyName: "reel_likes_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
reels: {
Row: {
author_id: string
caption: string
comments_count: number
created_at: string | null
id: string
likes_count: number
media_path: string
media_type: string
place_id: string
shares_count: number
thumbnail_path: string | null
}
Insert: {
author_id: string
caption?: string
comments_count?: number
created_at?: string | null
id?: string
likes_count?: number
media_path: string
media_type: string
place_id: string
shares_count?: number
thumbnail_path?: string | null
}
Update: {
author_id?: string
caption?: string
comments_count?: number
created_at?: string | null
id?: string
likes_count?: number
media_path?: string
media_type?: string
place_id?: string
shares_count?: number
thumbnail_path?: string | null
}
Relationships: [
{
foreignKeyName: "reels_author_id_fkey"
columns: ["author_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "reels_place_id_fkey"
columns: ["place_id"]
isOneToOne: false
referencedRelation: "places"
referencedColumns: ["id"]
},
]
}
subscriptions: {
Row: {
active_until: string | null
tier: string
updated_at: string | null
user_id: string
}
Insert: {
active_until?: string | null
tier?: string
updated_at?: string | null
user_id: string
}
Update: {
active_until?: string | null
tier?: string
updated_at?: string | null
user_id?: string
}
Relationships: []
}
swipes: {
Row: {
action: string
created_at: string | null
id: string
swiped_id: string
swiper_id: string
}
Insert: {
action: string
created_at?: string | null
id?: string
swiped_id: string
swiper_id: string
}
Update: {
action?: string
created_at?: string | null
id?: string
swiped_id?: string
swiper_id?: string
}
Relationships: [
{
foreignKeyName: "swipes_swiped_id_fkey"
columns: ["swiped_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "swipes_swiper_id_fkey"
columns: ["swiper_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {},
},
} as const
-425
View File
@@ -1,425 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonInput,
IonItem,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonRouterLink,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { mail, lockClosed, logoFacebook } from 'ionicons/icons';
import { IonIcon } from '@ionic/react';
import { enablePreviewMode } from '../utils/previewMode';
import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { Capacitor } from '@capacitor/core';
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
const AuthPage: React.FC = () => {
const history = useHistory();
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleAuth = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
if (mode === 'signin') {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
if (error.message.toLowerCase().includes('email not confirmed')) {
await supabase.auth.resend({ type: 'signup', email });
history.push('/verify-email', { email, type: 'signup' });
return;
}
throw error;
}
history.push('/icebreaker');
} else {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: { onboarding_complete: false },
},
});
if (error) throw error;
if (data.session) {
history.push('/onboarding/profile');
} else {
history.push('/verify-email', { email, type: 'signup' });
}
}
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
const enterPreviewMode = () => {
enablePreviewMode();
history.push('/icebreaker');
};
const handleSocialSignIn = async (provider: 'google' | 'apple') => {
if (!Capacitor.isNativePlatform()) {
showError(
`${provider === 'google' ? 'Google' : 'Apple'} Sign-In is only available on native devices.`
);
return;
}
setLoading(true);
try {
let result;
if (provider === 'google') {
result = await FirebaseAuthentication.signInWithGoogle();
} else {
result = await FirebaseAuthentication.signInWithApple({
skipNativeAuth: true,
});
}
const { error } = await supabase.auth.signInWithIdToken({
provider,
token: result.credential!.idToken!,
nonce: result.credential?.nonce,
});
if (error) throw error;
history.push('/icebreaker');
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
className="ion-padding"
style={{
'--background': 'var(--color-bg)',
display: 'flex',
flexDirection: 'column',
paddingTop: 'calc(var(--ion-safe-area-top) + 40px)',
}}
>
<div style={{ padding: '0 20px' }}>
<div style={{ marginBottom: '44px', paddingTop: '24px' }}>
<h1
style={{
fontSize: '28px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 42px',
}}
>
{mode === 'signin' ? 'Hi there, 👋' : 'Create account'}
</h1>
<h2
style={{
fontSize: '24px',
fontWeight: 700,
color: 'var(--color-text-primary)',
margin: '0 0 12px',
}}
>
{mode === 'signin'
? 'Sign in to continue'
: 'Sign up to continue'}
</h2>
<p
style={{
fontSize: '16px',
color: 'var(--color-text-secondary)',
margin: 0,
lineHeight: 1.35,
}}
>
{mode === 'signin'
? "Don't have an account? Enter your email and password or choose one of the options below."
: 'Enter your email and password to start discovering people and places nearby.'}
</p>
</div>
<div
style={{
display: 'flex',
background: 'var(--color-surface-raised)',
padding: '4px',
borderRadius: 'var(--radius-pill)',
marginBottom: '20px',
}}
>
{(['signin', 'signup'] as const).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
style={{
flex: 1,
padding: '10px',
borderRadius: 'var(--radius-pill)',
border: 'none',
fontSize: '14px',
fontWeight: 700,
transition: '0.2s',
background:
mode === m ? 'var(--ion-color-primary)' : 'transparent',
color: mode === m ? '#ffffff' : 'var(--color-text-primary)',
}}
>
{m === 'signin' ? 'Sign In' : 'Sign Up'}
</button>
))}
</div>
<form
onSubmit={handleAuth}
style={{
background: 'transparent',
padding: '0',
borderRadius: '0',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-input)',
'--border-radius': 'var(--radius-control)',
'--border-width': '1px',
'--border-style': 'solid',
'--border-color': 'var(--color-text-secondary)',
'--min-height': '48px',
}}
>
<IonIcon
icon={mail}
slot="start"
style={{ color: 'var(--ion-color-primary)', fontSize: '20px' }}
/>
<IonInput
label="Email"
labelPlacement="floating"
type="email"
value={email}
onIonInput={(e) => setEmail(e.detail.value!)}
required
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-input)',
'--border-radius': 'var(--radius-control)',
'--border-width': '1px',
'--border-style': 'solid',
'--border-color': 'var(--color-text-secondary)',
'--min-height': '48px',
}}
>
<IonIcon
icon={lockClosed}
slot="start"
style={{ color: 'var(--ion-color-primary)', fontSize: '20px' }}
/>
<IonInput
label="Password"
labelPlacement="floating"
type="password"
value={password}
onIonInput={(e) => setPassword(e.detail.value!)}
required
/>
</IonItem>
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
}}
>
{error}
</div>
)}
<IonButton
type="submit"
expand="block"
disabled={loading}
style={{
'--border-radius': 'var(--radius-pill)',
'--color': '#000000',
fontWeight: 800,
height: '48px',
margin: '8px 28px 0',
}}
>
{loading ? (
<IonSpinner name="crescent" />
) : mode === 'signin' ? (
'Sign in'
) : (
'Continue'
)}
</IonButton>
{mode === 'signin' && (
<IonRouterLink
routerLink="/forgot-password"
style={{
textAlign: 'center',
fontSize: '13px',
color: 'var(--ion-color-primary)',
marginTop: '8px',
}}
>
Forgot password?
</IonRouterLink>
)}
</form>
<div
style={{
textAlign: 'center',
margin: '56px 0 26px',
fontSize: '13px',
color: 'var(--color-text-tertiary)',
}}
>
OR
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
padding: '0 28px',
}}
>
<IonButton
expand="block"
onClick={() => handleSocialSignIn('apple')}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
style={{ marginRight: '10px' }}
>
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
Sign in with Apple
</IonButton>
<IonButton
expand="block"
onClick={() =>
showError(
'Facebook Sign-In is only available on native devices.'
)
}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<IonIcon
icon={logoFacebook}
style={{ marginRight: '10px', fontSize: '22px' }}
/>
Sign in with Facebook
</IonButton>
<IonButton
expand="block"
onClick={() => handleSocialSignIn('google')}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<span
style={{
fontSize: '24px',
fontWeight: 900,
marginRight: '12px',
}}
>
G
</span>
Sign in with Google
</IonButton>
<IonButton
expand="block"
onClick={enterPreviewMode}
style={{
flex: 1,
'--background': 'var(--ion-color-primary)',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
Preview with dummy data
</IonButton>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default AuthPage;
-311
View File
@@ -1,311 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonIcon,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonItem,
IonTextarea,
IonSelect,
IonSelectOption,
} from '@ionic/react';
import {
closeOutline,
cameraOutline,
imageOutline,
sendOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const CameraPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [caption, setCaption] = useState('');
const [placeId, setPlaceId] = useState<string | null>(null);
const [places, setPlaces] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [isPublishOpen, setIsPublishOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchPlaces();
});
const fetchPlaces = async () => {
const { data } = await supabase.from('places').select('id, name');
setPlaces(data || []);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewUrl(URL.createObjectURL(selectedFile));
setIsPublishOpen(true);
}
};
const handlePublish = async () => {
if (!file || !user || !placeId) {
setError('Please select a place and add a file.');
return;
}
setUploading(true);
try {
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
const filePath = `${fileName}`;
// 1. Upload to storage
const { error: uploadError } = await supabase.storage
.from('reels')
.upload(filePath, file);
if (uploadError) throw uploadError;
// 2. Create reel record
const { error: reelError } = await supabase.from('reels').insert({
author_id: user.id,
place_id: placeId,
caption,
media_path: filePath,
media_type: file.type.startsWith('video') ? 'video' : 'image',
});
if (reelError) throw reelError;
history.replace(`/reels/${placeId}`);
} catch (err: any) {
setError(err.message);
} finally {
setUploading(false);
}
};
return (
<IonPage>
<IonContent fullscreen style={{ '--background': '#000000' }}>
<div
style={{
position: 'absolute',
top: 'calc(14px + var(--ion-safe-area-top))',
left: '20px',
zIndex: 10,
}}
>
<IonButton
fill="clear"
onClick={() => history.goBack()}
style={{
'--background': 'rgba(0,0,0,0.3)',
'--color': '#ffffff',
'--border-radius': '50%',
width: '40px',
height: '40px',
margin: 0,
}}
>
<IonIcon icon={closeOutline} slot="icon-only" />
</IonButton>
</div>
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
}}
>
<div
style={{
width: '120px',
height: '120px',
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '24px',
}}
>
<IonIcon
icon={cameraOutline}
style={{ fontSize: '48px', opacity: 0.5 }}
/>
</div>
<h2
style={{ fontSize: '20px', fontWeight: 800, marginBottom: '8px' }}
>
Create a Reel
</h2>
<p
style={{
opacity: 0.6,
fontSize: '14px',
marginBottom: '32px',
textAlign: 'center',
padding: '0 40px',
}}
>
Share your experience at a local spot. Select a photo or video to
begin.
</p>
<label
style={{
background: 'var(--ion-color-primary)',
color: '#ffffff',
padding: '14px 32px',
borderRadius: 'var(--radius-pill)',
fontWeight: 700,
fontSize: '15px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<IonIcon icon={imageOutline} style={{ fontSize: '20px' }} />
Select Media
<input
type="file"
accept="image/*,video/*"
onChange={handleFileChange}
hidden
/>
</label>
</div>
{/* Publish Modal */}
<IonModal
isOpen={isPublishOpen}
onDidDismiss={() => {
setIsPublishOpen(false);
setPreviewUrl(null);
setFile(null);
}}
>
<IonHeader>
<IonToolbar>
<IonTitle>New Reel</IonTitle>
<IonButtons slot="start">
<IonButton onClick={() => setIsPublishOpen(false)}>
Cancel
</IonButton>
</IonButtons>
<IonButtons slot="end">
<IonButton
onClick={handlePublish}
disabled={uploading || !placeId}
>
{uploading ? <IonSpinner name="crescent" /> : 'Share'}
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
<div
style={{
width: '100px',
height: '140px',
borderRadius: '12px',
overflow: 'hidden',
background: '#000',
flexShrink: 0,
}}
>
{file?.type.startsWith('video') ? (
<video
src={previewUrl || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<img
src={previewUrl || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
alt="Preview"
/>
)}
</div>
<IonTextarea
placeholder="Write a caption..."
value={caption}
onIonInput={(e) => setCaption(e.detail.value!)}
rows={6}
style={{
'--background': 'var(--color-bg)',
'--padding-start': '12px',
'--border-radius': '12px',
}}
/>
</div>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Tag a place"
labelPlacement="floating"
placeholder="Select place"
value={placeId}
onIonChange={(e) => setPlaceId(e.detail.value)}
>
{places.map((p) => (
<IonSelectOption key={p.id} value={p.id}>
{p.name}
</IonSelectOption>
))}
</IonSelect>
</IonItem>
{error && (
<div
style={{
color: 'var(--ion-color-danger)',
fontSize: '13px',
marginTop: '12px',
padding: '0 4px',
}}
>
{error}
</div>
)}
</IonContent>
</IonModal>
</IonContent>
</IonPage>
);
};
export default CameraPage;
-263
View File
@@ -1,263 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonInput,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { chevronBackOutline, sendOutline } from 'ionicons/icons';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import Avatar from '../components/Avatar';
import { previewChats, previewMessages } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const ChatThreadPage: React.FC = () => {
const history = useHistory();
const { conversationId } = useParams<{ conversationId: string }>();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [messages, setMessages] = useState<any[]>([]);
const [otherUser, setOtherUser] = useState<any>(null);
const [text, setText] = useState('');
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLIonContentElement>(null);
const fetchThread = async () => {
setLoading(true);
if (previewMode) {
const chat =
previewChats.find((item) => item.id === conversationId) ||
previewChats[0];
setOtherUser(chat.otherUser);
setMessages(previewMessages[chat.id] || []);
setLoading(false);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
return;
}
if (!user) return;
try {
// 1. Get other participant
const { data: conv } = await supabase
.from('conversations')
.select(
`
match_id,
matches(
user_a:profiles!matches_user_a_fkey(id, full_name, avatar_path),
user_b:profiles!matches_user_b_fkey(id, full_name, avatar_path)
)
`
)
.eq('id', conversationId)
.single();
if (conv) {
const match = (conv as any).matches;
setOtherUser(match.user_a.id === user.id ? match.user_b : match.user_a);
}
// 2. Get messages
const { data: msgs } = await supabase
.from('messages')
.select('*')
.eq('conversation_id', conversationId)
.order('created_at', { ascending: true });
setMessages(msgs || []);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
} catch (err: any) {
console.error('Fetch thread error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchThread();
});
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim() || (!user && !previewMode) || sending) return;
setSending(true);
const msgBody = text.trim();
setText('');
if (previewMode) {
const previewMessage = {
id: `preview-message-${Date.now()}`,
sender_id: 'preview-me',
body: msgBody,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, previewMessage]);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
setSending(false);
return;
}
try {
const { data, error } = await supabase
.from('messages')
.insert({
conversation_id: conversationId,
sender_id: user.id,
body: msgBody,
})
.select()
.single();
if (error) throw error;
setMessages((prev) => [...prev, data]);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
// Invoke notification
supabase.functions.invoke('send-message-notification', {
body: { conversationId, messageId: data.id },
});
} catch (err: any) {
console.error('Send error:', err);
} finally {
setSending(false);
}
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
<IonButtons slot="start">
<IonButton
onClick={() => history.goBack()}
style={{ '--color': 'var(--color-text-primary)' }}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle>
{otherUser && (
<div
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
<Avatar
src={otherUser.avatar_path}
name={otherUser.full_name}
size={32}
/>
<span style={{ fontSize: '16px', fontWeight: 700 }}>
{otherUser.full_name}
</span>
</div>
)}
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent
ref={scrollRef}
style={{ '--background': 'var(--color-bg)' }}
className="ion-padding"
>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
paddingBottom: '20px',
}}
>
{messages.map((msg) => {
const isMine = previewMode
? msg.sender_id === 'preview-me'
: msg.sender_id === user?.id;
return (
<div
key={msg.id}
style={{
alignSelf: isMine ? 'flex-end' : 'flex-start',
maxWidth: '78%',
background: isMine
? 'var(--ion-color-primary)'
: 'var(--color-surface)',
color: isMine ? '#ffffff' : 'var(--color-text-primary)',
padding: '10px 14px',
borderRadius: isMine
? '18px 18px 4px 18px'
: '18px 18px 18px 4px',
fontSize: '14px',
lineHeight: 1.4,
boxShadow: '0 2px 8px rgba(76, 5, 25, 0.02)',
}}
>
{msg.body}
</div>
);
})}
</div>
)}
</IonContent>
<div
style={{
background: '#ffffff',
padding: '10px 20px calc(10px + var(--ion-safe-area-bottom))',
borderTop: '1px solid var(--color-border)',
}}
>
<form
onSubmit={handleSend}
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
>
<IonInput
value={text}
onIonInput={(e) => setText(e.detail.value!)}
placeholder="Type a message..."
style={{
background: 'var(--color-bg)',
'--border-radius': 'var(--radius-pill)',
'--padding-start': '16px',
fontSize: '14px',
}}
/>
<IonButton
type="submit"
disabled={!text.trim() || sending}
style={{
'--border-radius': '50%',
width: '44px',
height: '44px',
margin: 0,
flexShrink: 0,
}}
>
{sending ? (
<IonSpinner name="crescent" />
) : (
<IonIcon icon={sendOutline} slot="icon-only" />
)}
</IonButton>
</form>
</div>
</IonPage>
);
};
export default ChatThreadPage;
-218
View File
@@ -1,218 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonSpinner,
useIonViewWillEnter,
IonRefresher,
IonRefresherContent,
IonIcon,
} from '@ionic/react';
import { chatbubbleEllipsesOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { formatTime } from '../utils/format';
import SearchRow from '../components/SearchRow';
import Avatar from '../components/Avatar';
import EmptyState from '../components/EmptyState';
import { previewChats } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const ChatsPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [chats, setChats] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const fetchChats = async () => {
if (previewMode) {
setChats(previewChats);
setLoading(false);
return;
}
if (!user) return;
setLoading(true);
try {
const { data, error } = await supabase
.from('matches')
.select(
`
id,
last_message_at,
user_a:profiles!matches_user_a_fkey(id, full_name, avatar_path),
user_b:profiles!matches_user_b_fkey(id, full_name, avatar_path),
conversations(
id,
messages(
body,
created_at
)
)
`
)
.or(`user_a.eq.${user.id},user_b.eq.${user.id}`)
.order('last_message_at', { ascending: false });
if (error) throw error;
const formatted =
data?.map((m) => {
const otherUser = m.user_a.id === user.id ? m.user_b : m.user_a;
const conversation = m.conversations?.[0];
const lastMsg = conversation?.messages?.[0]; // ordered by handle-swipe or trigger later
return {
id: conversation?.id,
otherUser,
lastMessage: lastMsg?.body || 'New match! Start chatting.',
timestamp: m.last_message_at || lastMsg?.created_at,
};
}) || [];
setChats(formatted);
} catch (err: any) {
console.error('Fetch chats error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchChats();
});
const handleRefresh = async (event: CustomEvent) => {
await fetchChats();
event.detail.complete();
};
const displayChats = previewMode ? previewChats : chats;
const filteredChats = displayChats.filter(
(c) =>
c.otherUser.full_name.toLowerCase().includes(query.toLowerCase()) ||
c.lastMessage.toLowerCase().includes(query.toLowerCase())
);
return (
<IonPage>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div
style={{ padding: 'calc(16px + var(--ion-safe-area-top)) 20px 12px' }}
>
<h1
style={{
fontSize: '24px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: 0,
}}
>
Chats
</h1>
</div>
<SearchRow
value={query}
onChange={(e) => setQuery(e.detail.value!)}
placeholder="Search conversations"
/>
<div
style={{ paddingBottom: 'calc(92px + var(--ion-safe-area-bottom))' }}
>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : filteredChats.length === 0 ? (
<EmptyState
icon={chatbubbleEllipsesOutline}
title="No chats yet"
message="Matches you message will appear here. Start swiping to find someone!"
ctaText="Find people"
onCtaClick={() => history.push('/icebreaker')}
/>
) : (
filteredChats.map((chat) => (
<div
key={chat.id}
onClick={() => history.push(`/chat/${chat.id}`)}
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
margin: '0 20px 12px',
padding: '16px',
display: 'flex',
alignItems: 'center',
gap: '12px',
boxShadow: 'var(--shadow-card)',
}}
>
<Avatar
src={chat.otherUser.image || chat.otherUser.avatar_path}
name={chat.otherUser.full_name}
size={56}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '4px',
}}
>
<h3
style={{
fontSize: '16px',
fontWeight: 800,
margin: 0,
color: 'var(--color-text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{chat.otherUser.full_name}
</h3>
<span
style={{
fontSize: '11px',
color: 'var(--color-text-tertiary)',
fontWeight: 600,
}}
>
{formatTime(chat.timestamp)}
</span>
</div>
<p
style={{
fontSize: '14px',
color: 'var(--color-text-secondary)',
margin: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{chat.lastMessage}
</p>
</div>
</div>
))
)}
</div>
</IonContent>
</IonPage>
);
};
export default ChatsPage;
-378
View File
@@ -1,378 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonItem,
IonInput,
IonTextarea,
IonSelect,
IonSelectOption,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import {
chevronBackOutline,
cameraOutline,
trashOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { getStorageUrl } from '../utils/storage';
const EditProfilePage: React.FC = () => {
const history = useHistory();
const { user, profile, refreshProfile } = useAuth();
const [fullName, setFullName] = useState(profile?.full_name || '');
const [bio, setBio] = useState(profile?.bio || '');
const [gender, setGender] = useState(profile?.gender || '');
const [relationshipGoal, setRelationshipGoal] = useState(
profile?.relationship_goal || ''
);
const [loading, setLoading] = useState(false);
const [photos, setPhotos] = useState<any[]>([]);
const [uploading, setUploading] = useState(false);
const fetchPhotos = async () => {
if (!user) return;
const { data } = await supabase
.from('profile_photos')
.select('*')
.eq('user_id', user.id)
.order('position');
setPhotos(data || []);
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
if (profile) {
setFullName(profile.full_name);
setBio(profile.bio || '');
setGender(profile.gender || '');
setRelationshipGoal(profile.relationship_goal || '');
}
fetchPhotos();
});
const handleSave = async () => {
if (!user) return;
setLoading(true);
try {
const { error } = await supabase
.from('profiles')
.update({
full_name: fullName,
bio,
gender,
relationship_goal: relationshipGoal,
})
.eq('id', user.id);
if (error) throw error;
await refreshProfile();
history.goBack();
} catch (err: any) {
console.error('Save error:', err);
} finally {
setLoading(false);
}
};
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !user) return;
setUploading(true);
try {
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
const filePath = `profile-photos/${fileName}`;
const { error: uploadError } = await supabase.storage
.from('profile-photos')
.upload(filePath, file);
if (uploadError) throw uploadError;
const { error: dbError } = await supabase.from('profile_photos').insert({
user_id: user.id,
storage_path: filePath,
position: photos.length,
});
if (dbError) throw dbError;
fetchPhotos();
} catch (err: any) {
console.error('Photo upload error:', err);
} finally {
setUploading(false);
}
};
const deletePhoto = async (id: string, path: string) => {
try {
await supabase.from('profile_photos').delete().eq('id', id);
await supabase.storage.from('profile-photos').remove([path]);
fetchPhotos();
} catch (err) {
console.error('Delete photo error:', err);
}
};
const goals = [
{ label: 'Long-term partner', value: 'long_term' },
{ label: 'Long-term, but short-term OK', value: 'long_term_short_ok' },
{ label: 'Short-term, but long-term OK', value: 'short_term_long_ok' },
{ label: 'Short-term fun', value: 'short_term' },
{ label: 'New friends', value: 'friends' },
{ label: 'Still figuring it out', value: 'figuring_it_out' },
];
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
<IonButtons slot="start">
<IonButton
onClick={() => history.goBack()}
style={{ '--color': 'var(--color-text-primary)' }}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle>Edit Profile</IonTitle>
<IonButtons slot="end">
<IonButton
onClick={handleSave}
disabled={loading}
style={{ fontWeight: 700 }}
>
{loading ? <IonSpinner name="crescent" /> : 'Save'}
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent
className="ion-padding"
style={{ '--background': 'var(--color-bg)' }}
>
{/* Photo Manager */}
<div style={{ marginBottom: '24px' }}>
<h3
style={{
fontSize: '13px',
fontWeight: 800,
color: 'var(--color-text-tertiary)',
textTransform: 'uppercase',
marginBottom: '12px',
}}
>
Profile Photos
</h3>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '10px',
}}
>
{photos.map((photo) => (
<div
key={photo.id}
style={{
paddingBottom: '125%',
background: 'var(--color-surface-raised)',
borderRadius: '12px',
position: 'relative',
overflow: 'hidden',
}}
>
<img
src={
getStorageUrl('profile-photos', photo.storage_path) || ''
}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
alt="Profile"
/>
<button
onClick={() => deletePhoto(photo.id, photo.storage_path)}
style={{
position: 'absolute',
top: '8px',
right: '8px',
background: 'rgba(0,0,0,0.5)',
border: 'none',
borderRadius: '50%',
width: '24px',
height: '24px',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon icon={trashOutline} style={{ fontSize: '14px' }} />
</button>
</div>
))}
{photos.length < 6 && (
<label
style={{
paddingBottom: '125%',
background: 'var(--color-surface)',
borderRadius: '12px',
position: 'relative',
border: '2px dashed var(--ion-color-primary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{uploading ? (
<IonSpinner name="crescent" />
) : (
<IonIcon
icon={cameraOutline}
style={{
fontSize: '24px',
color: 'var(--ion-color-primary)',
}}
/>
)}
</div>
<input
type="file"
accept="image/*"
onChange={handlePhotoUpload}
hidden
disabled={uploading}
/>
</label>
)}
</div>
</div>
{/* Basic Info */}
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '16px',
boxShadow: 'var(--shadow-card)',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="Full Name"
labelPlacement="floating"
value={fullName}
onIonInput={(e) => setFullName(e.detail.value!)}
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Gender"
labelPlacement="floating"
value={gender}
onIonChange={(e) => setGender(e.detail.value)}
interface="action-sheet"
>
<IonSelectOption value="woman">Woman</IonSelectOption>
<IonSelectOption value="man">Man</IonSelectOption>
<IonSelectOption value="nonbinary">Non-binary</IonSelectOption>
<IonSelectOption value="other">Other</IonSelectOption>
<IonSelectOption value="prefer_not_to_say">
Prefer not to say
</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Relationship Goal"
labelPlacement="floating"
value={relationshipGoal}
onIonChange={(e) => setRelationshipGoal(e.detail.value)}
interface="action-sheet"
>
{goals.map((g) => (
<IonSelectOption key={g.value} value={g.value}>
{g.label}
</IonSelectOption>
))}
</IonSelect>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonTextarea
label="Bio"
labelPlacement="floating"
value={bio}
onIonInput={(e) => setBio(e.detail.value!)}
placeholder="Tell us about yourself..."
rows={4}
/>
</IonItem>
</div>
</IonContent>
</IonPage>
);
};
export default EditProfilePage;
-153
View File
@@ -1,153 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonInput,
IonItem,
IonButton,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const ForgotPasswordPage: React.FC = () => {
const history = useHistory();
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleReset = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const { error } = await supabase.auth.resetPasswordForEmail(email);
if (error) throw error;
history.push('/verify-email', { email, type: 'recovery' });
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background': 'var(--color-bg)',
}}
>
<div style={{ padding: 'calc(var(--ion-safe-area-top) + 40px) 20px' }}>
<div style={{ marginBottom: '32px' }}>
<h1
style={{
fontSize: '28px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 8px',
}}
>
Reset Password
</h1>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: 0,
lineHeight: 1.5,
}}
>
Enter your email and we'll send you a code to reset your password.
</p>
</div>
<form
onSubmit={handleReset}
style={{
background: 'var(--color-surface)',
padding: '24px',
borderRadius: 'var(--radius-card)',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="Email"
labelPlacement="floating"
type="email"
value={email}
onIonInput={(e) => setEmail(e.detail.value!)}
required
/>
</IonItem>
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
}}
>
{error}
</div>
)}
<IonButton
type="submit"
expand="block"
disabled={loading}
style={{
'--border-radius': 'var(--radius-pill)',
height: '48px',
marginTop: '8px',
}}
>
{loading ? <IonSpinner name="crescent" /> : 'Send Code'}
</IonButton>
<button
type="button"
onClick={() => history.goBack()}
style={{
background: 'none',
border: 'none',
color: 'var(--color-text-tertiary)',
fontSize: '14px',
fontWeight: 600,
marginTop: '12px',
}}
>
Back to Sign In
</button>
</form>
</div>
</IonContent>
</IonPage>
);
};
export default ForgotPasswordPage;
-558
View File
@@ -1,558 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonIcon,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonRefresher,
IonRefresherContent,
} from '@ionic/react';
import {
locationOutline,
notificationsOutline,
star,
cameraOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { formatDistance } from '../utils/format';
import { getStorageUrl } from '../utils/storage';
import SearchRow from '../components/SearchRow';
import EmptyState from '../components/EmptyState';
import { previewPlaces } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const HangoutPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [places, setPlaces] = useState<any[]>([]);
const [reelStories, setReelStories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const [sort, setSort] = useState<'most_busy' | 'nearest' | 'top_rated'>(
'most_busy'
);
const fetchPlaces = async () => {
setLoading(true);
if (previewMode) {
setPlaces(previewPlaces);
setReelStories(previewPlaces);
setLoading(false);
return;
}
try {
// Phase 1: simple local query. Phase 2: use places-nearby Edge Function
const { data, error } = await supabase
.from('places')
.select(
`
*,
reels(id)
`
)
.order('rating', { ascending: false });
if (error) throw error;
setPlaces(data || []);
// Derive stories (places with reels)
const stories =
data?.filter((p) => p.reels?.length > 0).slice(0, 10) || [];
setReelStories(stories);
} catch (err: any) {
console.error('Fetch places error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchPlaces();
});
const handleRefresh = async (event: CustomEvent) => {
await fetchPlaces();
event.detail.complete();
};
const displayPlaces = previewMode ? previewPlaces : places;
const displayStories = previewMode ? previewPlaces : reelStories;
const filteredPlaces = displayPlaces.filter(
(p) =>
p.name.toLowerCase().includes(query.toLowerCase()) ||
p.category.toLowerCase().includes(query.toLowerCase())
);
return (
<IonPage>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div
style={{
padding: 'calc(14px + var(--ion-safe-area-top)) 20px 8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '12px',
background: 'rgba(255,255,255,0.06)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={locationOutline}
style={{ color: 'var(--ion-color-primary)', fontSize: '18px' }}
/>
</div>
<div
style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}
>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--color-text-tertiary)',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}
>
Nearby now
</span>
<span
style={{
fontSize: '17px',
fontWeight: 700,
color: 'var(--color-text-primary)',
lineHeight: 1.1,
}}
>
Midrand, ZA
</span>
</div>
</div>
<IonButton
fill="clear"
onClick={() => history.push('/notifications')}
style={{
'--background': 'rgba(255,255,255,0.06)',
'--color': 'var(--color-text-primary)',
'--border-radius': '12px',
width: '44px',
height: '44px',
margin: 0,
'--padding-start': 0,
'--padding-end': 0,
}}
>
<IonIcon
icon={notificationsOutline}
slot="icon-only"
style={{ fontSize: '20px' }}
/>
</IonButton>
</div>
<SearchRow
value={query}
onChange={(e) => setQuery(e.detail.value!)}
placeholder="Search place of interest"
/>
<div
style={{
display: 'flex',
gap: '10px',
padding: '2px 20px 26px',
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}
>
{(['most_busy', 'nearest', 'top_rated'] as const).map((s) => {
const active = sort === s;
return (
<button
key={s}
onClick={() => setSort(s)}
style={{
padding: '10px 16px',
borderRadius: 'var(--radius-pill)',
border: 'none',
fontSize: '14px',
fontWeight: active ? 700 : 600,
whiteSpace: 'nowrap',
background: active
? 'linear-gradient(180deg, #f24f88 0%, var(--ion-color-primary) 100%)'
: 'rgba(255,255,255,0.05)',
color: active ? '#ffffff' : 'rgba(255,255,255,0.82)',
boxShadow: active
? '0 10px 24px rgba(254, 60, 114, 0.26)'
: 'none',
transition: '0.2s ease',
flexShrink: 0,
}}
>
{s.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
</button>
);
})}
</div>
{/* Reels Stories Section */}
<div style={{ marginBottom: '26px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0 20px 14px',
}}
>
<h2
style={{
fontSize: '18px',
fontWeight: 800,
margin: 0,
color: 'var(--color-text-primary)',
letterSpacing: '-0.02em',
}}
>
Reels
</h2>
<button
onClick={() => history.push('/camera')}
style={{
width: '44px',
height: '44px',
borderRadius: '12px',
border: 'none',
background:
'linear-gradient(180deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.06) 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
flexShrink: 0,
}}
>
<IonIcon
icon={cameraOutline}
style={{ fontSize: '20px', color: 'var(--color-text-primary)' }}
/>
</button>
</div>
<div
style={{
display: 'flex',
gap: '14px',
padding: '0 20px',
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}
>
<div style={{ flexShrink: 0, width: '76px', textAlign: 'center' }}>
<div
onClick={() => history.push('/camera')}
style={{
width: '68px',
height: '68px',
borderRadius: '50%',
padding: '2px',
background:
'linear-gradient(180deg, #f24f88 0%, #a60b4a 100%)',
marginBottom: '8px',
}}
>
<div
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
background: '#08080a',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon
icon={cameraOutline}
style={{
fontSize: '22px',
color: 'var(--ion-color-primary)',
}}
/>
</div>
</div>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--color-text-primary)',
opacity: 0.95,
}}
>
My reels
</span>
</div>
{displayStories.map((story) => (
<div
key={story.id}
onClick={() => history.push(`/reels/${story.id}`)}
style={{ flexShrink: 0, width: '76px', textAlign: 'center' }}
>
<div
style={{
width: '68px',
height: '68px',
borderRadius: '50%',
padding: '2px',
background:
'linear-gradient(180deg, #f24f88 0%, #a60b4a 100%)',
marginBottom: '8px',
}}
>
<div
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
padding: '3px',
background: '#08080a',
}}
>
<img
src={
story.image ||
getStorageUrl('place-media', story.image_path) ||
''
}
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
objectFit: 'cover',
}}
alt={story.name}
/>
</div>
</div>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--color-text-primary)',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
opacity: 0.95,
}}
>
{story.name}
</span>
</div>
))}
</div>
</div>
{/* Places List */}
<div
style={{ paddingBottom: 'calc(92px + var(--ion-safe-area-bottom))' }}
>
<h2
style={{
fontSize: '18px',
fontWeight: 800,
margin: '0 20px 16px',
color: 'var(--color-text-primary)',
}}
>
Places of interest 📍
</h2>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : filteredPlaces.length === 0 ? (
<EmptyState
icon={locationOutline}
title="No places found"
message="We couldn't find any places matching your search nearby."
ctaText="Refresh"
onCtaClick={fetchPlaces}
/>
) : (
filteredPlaces.map((place, index) => (
<div
key={place.id}
onClick={() => history.push(`/reels/${place.id}`)}
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
margin: '0 20px 12px',
padding: '12px',
display: 'flex',
gap: '12px',
boxShadow: 'var(--shadow-card)',
}}
>
<div
style={{
flexShrink: 0,
width: '112px',
height: '88px',
borderRadius: 'var(--radius-control)',
overflow: 'hidden',
}}
>
<img
src={
place.image ||
getStorageUrl('place-media', place.image_path) ||
''
}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
alt={place.name}
/>
</div>
<div style={{ flex: 1, position: 'relative' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<h3
style={{
fontSize: '16px',
fontWeight: 800,
margin: '0 0 4px',
color: 'var(--color-text-primary)',
}}
>
{index + 1}. {place.name}
</h3>
</div>
<p
style={{
fontSize: '12px',
color: 'var(--color-text-secondary)',
margin: '0 0 4px',
}}
>
{place.category}
</p>
<p
style={{
fontSize: '12px',
color: 'var(--color-text-tertiary)',
margin: '0 0 8px',
}}
>
{place.distance || '0.9 mi'}
</p>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<div style={{ display: 'flex', gap: '1px' }}>
{[1, 2, 3, 4, 5].map((s) => (
<IonIcon
key={s}
icon={star}
style={{
fontSize: '12px',
color:
s <= place.rating
? 'var(--ion-color-primary)'
: 'var(--color-border)',
}}
/>
))}
</div>
<span
style={{
fontSize: '11px',
color: 'var(--color-text-tertiary)',
fontWeight: 600,
}}
>
{place.review_count > 1000
? `${(place.review_count / 1000).toFixed(1)}k`
: place.review_count}{' '}
Reviews
</span>
</div>
{place.busyness === 'busy' && (
<div
style={{
position: 'absolute',
top: 0,
right: 0,
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<span
style={{
fontSize: '10px',
color: 'var(--color-text-tertiary)',
fontWeight: 600,
}}
>
Busy
</span>
<div
style={{
background: 'var(--ion-color-primary)',
color: '#ffffff',
fontSize: '9px',
fontWeight: 800,
padding: '2px 6px',
borderRadius: 'var(--radius-pill)',
}}
>
LIVE
</div>
</div>
)}
</div>
</div>
))
)}
</div>
</IonContent>
</IonPage>
);
};
export default HangoutPage;
-699
View File
@@ -1,699 +0,0 @@
import React, { useRef, useState } from 'react';
import {
IonButton,
IonContent,
IonIcon,
IonPage,
useIonViewWillEnter,
} from '@ionic/react';
import {
apps,
close,
heart,
optionsOutline,
paperPlane,
refreshOutline,
walk,
} from 'ionicons/icons';
import Lottie from 'lottie-react';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { getStorageUrl } from '../utils/storage';
import { previewProfiles } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
import heartsAnimation from '../assets/images/hearts.json';
type SwipeAction = 'like' | 'skip';
type DragState = {
x: number;
y: number;
startX: number;
startY: number;
active: boolean;
};
const SWIPE_THRESHOLD = 105;
const EXIT_DISTANCE = 620;
const calculateAge = (birthDate?: string | null, fallback?: number) => {
if (fallback) return fallback;
if (!birthDate) return null;
const birth = new Date(birthDate);
if (Number.isNaN(birth.getTime())) return null;
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age -= 1;
}
return age;
};
const IcebreakerPage: React.FC = () => {
const { user } = useAuth();
const previewMode = isPreviewMode();
const cardRef = useRef<HTMLDivElement | null>(null);
const [profiles, setProfiles] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
const [swiping, setSwiping] = useState(false);
const [showHearts, setShowHearts] = useState(false);
const [drag, setDrag] = useState<DragState | null>(null);
const [exitDirection, setExitDirection] = useState<SwipeAction | null>(null);
const fetchProfiles = async () => {
if (previewMode) {
setProfiles(previewProfiles);
setCurrentIndex(0);
setLoading(false);
return;
}
if (!user) {
setLoading(false);
return;
}
setLoading(true);
try {
const { data: swipedData } = await supabase
.from('swipes')
.select('swiped_id')
.eq('swiper_id', user.id);
const swipedIds = swipedData?.map((s) => s.swiped_id) || [];
const excludedIds = [user.id, ...swipedIds].filter(Boolean);
let query = supabase
.from('profiles')
.select('*, profile_photos(storage_path, position)')
.neq('id', user.id)
.limit(20);
if (excludedIds.length > 0) {
query = query.not('id', 'in', `(${excludedIds.join(',')})`);
}
const { data, error } = await query;
if (error) throw error;
setProfiles(data || []);
setCurrentIndex(0);
} catch (err) {
console.error('Profile fetch error:', err);
setProfiles([]);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchProfiles();
});
const displayProfiles = previewMode ? previewProfiles : profiles;
const currentProfile = displayProfiles.length
? displayProfiles[currentIndex % displayProfiles.length]
: null;
const nextProfile =
displayProfiles.length > 1
? displayProfiles[(currentIndex + 1) % displayProfiles.length]
: null;
const getProfileImages = (profile: any) => {
if (!profile) return [];
if (Array.isArray(profile.images) && profile.images.length > 0) {
return profile.images;
}
if (
Array.isArray(profile.profile_photos) &&
profile.profile_photos.length > 0
) {
return profile.profile_photos
.slice()
.sort((a: any, b: any) => (a.position || 0) - (b.position || 0))
.map((photo: any) =>
getStorageUrl('profile-photos', photo.storage_path)
)
.filter(Boolean);
}
return [
profile.image ||
profile.avatar ||
getStorageUrl('avatars', profile.avatar_path) ||
'',
].filter(Boolean);
};
const getProfileImage = (profile: any) => getProfileImages(profile)[0] || '';
const commitSwipe = async (action: SwipeAction) => {
if (displayProfiles.length === 0) return;
if (previewMode) {
setCurrentIndex((prev) => (prev + 1) % displayProfiles.length);
return;
}
const targetUserId = profiles[currentIndex]?.id;
if (!targetUserId) return;
try {
const { data, error } = await supabase.functions.invoke('handle-swipe', {
body: { targetUserId, action },
});
if (error) throw error;
if (data?.matched && action === 'like') {
setShowHearts(true);
setTimeout(() => setShowHearts(false), 1250);
}
setCurrentIndex((prev) => prev + 1);
} catch (err) {
console.error('Swipe error:', err);
}
};
const animateSwipe = (action: SwipeAction) => {
if (swiping || !currentProfile) return;
setSwiping(true);
setExitDirection(action);
setDrag((prev) => ({
x: action === 'like' ? EXIT_DISTANCE : -EXIT_DISTANCE,
y: prev?.y || -12,
startX: prev?.startX || 0,
startY: prev?.startY || 0,
active: false,
}));
if (action === 'like') {
setShowHearts(true);
setTimeout(() => setShowHearts(false), 1250);
}
window.setTimeout(async () => {
await commitSwipe(action);
setDrag(null);
setExitDirection(null);
setSwiping(false);
}, 280);
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (swiping || !currentProfile) return;
cardRef.current?.setPointerCapture(event.pointerId);
setDrag({
x: 0,
y: 0,
startX: event.clientX,
startY: event.clientY,
active: true,
});
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!drag?.active || swiping) return;
setDrag({
...drag,
x: event.clientX - drag.startX,
y: event.clientY - drag.startY,
});
};
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (!drag || swiping) return;
cardRef.current?.releasePointerCapture(event.pointerId);
const absX = Math.abs(drag.x);
const absY = Math.abs(drag.y);
if (absX < 8 && absY < 8) {
const isRightHalf = event.clientX > window.innerWidth / 2;
setDrag(null);
animateSwipe(isRightHalf ? 'like' : 'skip');
return;
}
if (drag.x > SWIPE_THRESHOLD) {
animateSwipe('like');
return;
}
if (drag.x < -SWIPE_THRESHOLD) {
animateSwipe('skip');
return;
}
setDrag({ ...drag, x: 0, y: 0, active: false });
window.setTimeout(() => setDrag(null), 180);
};
const dragX = drag?.x || 0;
const dragY = drag?.y || 0;
const rotate = Math.max(-12, Math.min(12, dragX / 22));
const transition = drag?.active
? 'none'
: 'transform 280ms cubic-bezier(.2,.9,.22,1), opacity 220ms ease';
const topTransform = `translate3d(${dragX}px, ${dragY}px, 0) rotate(${rotate}deg)`;
const name = currentProfile?.full_name || currentProfile?.name || 'Nearby';
const age = calculateAge(currentProfile?.birth_date, currentProfile?.age);
const distance =
currentProfile?.distance || currentProfile?.travelTime || '3 min';
const interests =
currentProfile?.interests || currentProfile?.userProfile?.interests || [];
const currentImages = getProfileImages(currentProfile);
const currentImage = currentImages[0] || getProfileImage(currentProfile);
const topProgressBars =
currentImages.length > 0 ? currentImages : [currentImage];
return (
<IonPage>
<IonContent
fullscreen
style={{ '--background': '#000000' }}
scrollY={false}
>
<main
style={{
position: 'fixed',
inset: 0,
background: '#000000',
overflow: 'hidden',
touchAction: 'none',
}}
>
{loading && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
fontSize: 15,
}}
>
Finding people nearby
</div>
)}
{!loading && nextProfile && (
<article
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
right: 0,
top: 'calc(var(--ion-safe-area-top, 0px) + 6px)',
bottom: 'calc(76px + var(--ion-safe-area-bottom, 0px))',
borderRadius: 0,
overflow: 'hidden',
background: '#111111',
transform: 'scale(0.975)',
}}
>
<img
src={getProfileImage(nextProfile)}
alt="Next profile"
draggable={false}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
userSelect: 'none',
}}
/>
</article>
)}
{!loading && currentProfile && (
<article
ref={cardRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={{
position: 'absolute',
left: 0,
right: 0,
top: 'calc(var(--ion-safe-area-top, 0px) + 6px)',
bottom: 'calc(76px + var(--ion-safe-area-bottom, 0px))',
borderRadius: 0,
transform: topTransform,
transition,
opacity: exitDirection ? 0.9 : 1,
overflow: 'hidden',
background: '#111111',
cursor: drag?.active ? 'grabbing' : 'grab',
willChange: 'transform, opacity',
}}
>
<img
key={currentProfile.id}
src={currentImage}
alt={name}
draggable={false}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
userSelect: 'none',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
inset: 0,
background:
'linear-gradient(180deg, rgba(0,0,0,0.08) 0%, rgba(0,0,0,0.02) 45%, rgba(0,0,0,0.62) 73%, rgba(0,0,0,0.96) 100%)',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
top: 7,
left: 8,
right: 8,
display: 'grid',
gridTemplateColumns: `repeat(${topProgressBars.length}, 1fr)`,
gap: 4,
zIndex: 4,
}}
>
{topProgressBars.map((_, index) => (
<div
key={`media-progress-${index}`}
style={{
height: 3,
borderRadius: 999,
background:
index === 0
? 'rgba(255,255,255,0.94)'
: 'rgba(30,30,30,0.72)',
}}
/>
))}
</div>
<IonButton
fill="clear"
aria-label="Filter profiles"
style={{
position: 'absolute',
top: 24,
right: 14,
zIndex: 8,
'--color': '#fe3c72',
'--background': 'rgba(28, 28, 28, 0.76)',
'--border-radius': '999px',
width: 48,
height: 48,
}}
>
<IonIcon
icon={optionsOutline}
slot="icon-only"
style={{ fontSize: 27 }}
/>
</IonButton>
<section
style={{
position: 'absolute',
left: 14,
right: 14,
bottom: '34px',
zIndex: 4,
color: '#ffffff',
pointerEvents: 'none',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 14,
marginBottom: 22,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
textShadow: '0 2px 14px rgba(0,0,0,0.75)',
}}
>
<h1
style={{
margin: '0 0 4px',
fontSize: 30,
lineHeight: 1,
fontWeight: 900,
letterSpacing: -0.8,
}}
>
{name}, {age || ''}
</h1>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
color: '#ffffff',
fontSize: 18,
fontWeight: 800,
}}
>
<IonIcon
icon={walk}
style={{
color: '#ffffff',
flexShrink: 0,
fontSize: 25,
}}
/>
<span>{distance}</span>
</div>
</div>
<IonButton
aria-label="Send heart message"
onClick={(event) => {
event.stopPropagation();
animateSwipe('like');
}}
style={{
'--background': '#fe3c72',
'--color': '#111111',
'--border-radius': '999px',
width: 52,
height: 52,
marginTop: 2,
pointerEvents: 'auto',
flexShrink: 0,
}}
>
<IonIcon
icon={paperPlane}
slot="icon-only"
style={{ fontSize: 24 }}
/>
</IonButton>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 10,
fontSize: 21,
fontWeight: 900,
textShadow: '0 2px 12px rgba(0,0,0,0.8)',
}}
>
<IonIcon
icon={apps}
style={{ color: '#ffffff', fontSize: 32, flexShrink: 0 }}
/>
<span>Interests</span>
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
maxWidth: '100%',
}}
>
{interests
.slice(0, 5)
.map((interest: string, index: number) => (
<span
key={interest}
style={{
padding: '7px 13px',
borderRadius: 999,
background:
index === 0 || index === 3
? '#fe3c72'
: 'rgba(44, 53, 57, 0.78)',
color: '#ffffff',
fontSize: 16,
lineHeight: 1,
fontWeight: 650,
backdropFilter: 'blur(12px)',
maxWidth: '100%',
}}
>
{interest}
</span>
))}
</div>
</section>
</article>
)}
{!loading && displayProfiles.length === 0 && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 14,
color: '#ffffff',
padding: 24,
textAlign: 'center',
}}
>
<div style={{ fontSize: 18, fontWeight: 800 }}>
No nearby profiles right now.
</div>
<IonButton
onClick={fetchProfiles}
style={{
'--background': '#fe3c72',
'--border-radius': '999px',
}}
>
<IonIcon icon={refreshOutline} slot="start" />
Refresh
</IonButton>
</div>
)}
{!loading && currentProfile && (
<div
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 'calc(24px + var(--ion-safe-area-bottom, 0px))',
zIndex: 10,
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
padding: '0 54px',
pointerEvents: 'none',
}}
>
<IonButton
aria-label="Skip"
onClick={() => animateSwipe('skip')}
style={{
'--background': 'rgba(45,45,45,0.92)',
'--color': '#ff9f0a',
'--border-radius': '999px',
width: 70,
height: 70,
pointerEvents: 'auto',
}}
>
<IonIcon
icon={close}
slot="icon-only"
style={{ fontSize: 40, fontWeight: 900 }}
/>
</IonButton>
<IonButton
aria-label="Like"
onClick={() => animateSwipe('like')}
style={{
'--background': 'rgba(45,45,45,0.92)',
'--color': '#ff0000',
'--border-radius': '999px',
width: 70,
height: 70,
pointerEvents: 'auto',
}}
>
<IonIcon
icon={heart}
slot="icon-only"
style={{ fontSize: 42 }}
/>
</IonButton>
<IonButton
aria-label="Message"
onClick={() => animateSwipe('like')}
style={{
'--background': 'rgba(45,45,45,0.92)',
'--color': '#1683ff',
'--border-radius': '999px',
width: 70,
height: 70,
pointerEvents: 'auto',
}}
>
<IonIcon
icon={paperPlane}
slot="icon-only"
style={{ fontSize: 38 }}
/>
</IonButton>
</div>
)}
{showHearts && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<Lottie
animationData={heartsAnimation}
loop={false}
style={{ width: 310 }}
/>
</div>
)}
</main>
</IonContent>
</IonPage>
);
};
export default IcebreakerPage;
-165
View File
@@ -1,165 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonInput,
IonItem,
IonButton,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const NewPasswordPage: React.FC = () => {
const history = useHistory();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
showError("Passwords don't match");
return;
}
if (password.length < 6) {
showError('Password must be at least 6 characters');
return;
}
setLoading(true);
try {
const { error } = await supabase.auth.updateUser({ password });
if (error) throw error;
history.push('/icebreaker');
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background': 'var(--color-bg)',
}}
>
<div style={{ padding: 'calc(var(--ion-safe-area-top) + 40px) 20px' }}>
<div style={{ marginBottom: '32px' }}>
<h1
style={{
fontSize: '28px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 8px',
}}
>
Create New Password
</h1>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: 0,
lineHeight: 1.5,
}}
>
Choose a strong password for your InSpot account.
</p>
</div>
<form
onSubmit={handleUpdate}
style={{
background: 'var(--color-surface)',
padding: '24px',
borderRadius: 'var(--radius-card)',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="New Password"
labelPlacement="floating"
type="password"
value={password}
onIonInput={(e) => setPassword(e.detail.value!)}
required
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="Confirm Password"
labelPlacement="floating"
type="password"
value={confirmPassword}
onIonInput={(e) => setConfirmPassword(e.detail.value!)}
required
/>
</IonItem>
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
}}
>
{error}
</div>
)}
<IonButton
type="submit"
expand="block"
disabled={loading}
style={{
'--border-radius': 'var(--radius-pill)',
height: '48px',
marginTop: '8px',
}}
>
{loading ? <IonSpinner name="crescent" /> : 'Update Password'}
</IonButton>
</form>
</div>
</IonContent>
</IonPage>
);
};
export default NewPasswordPage;
-242
View File
@@ -1,242 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import {
chevronBackOutline,
notificationsOutline,
heart,
chatbubble,
people,
flash,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { formatTime } from '../utils/format';
import EmptyState from '../components/EmptyState';
import { previewNotifications } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
import Lottie from 'lottie-react';
import notificationsAnimation from '../assets/images/InSpotNotifications.json';
const NotificationsPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [notifications, setNotifications] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const fetchNotifications = async () => {
if (previewMode) {
setNotifications(previewNotifications);
setLoading(false);
return;
}
if (!user) return;
setLoading(true);
try {
const { data, error } = await supabase
.from('notifications')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false });
if (error) throw error;
setNotifications(data || []);
// Mark as read after a short delay
if (data && data.some((n) => !n.read_at)) {
setTimeout(async () => {
await supabase
.from('notifications')
.update({ read_at: new Date().toISOString() })
.eq('user_id', user.id)
.is('read_at', null);
}, 2000);
}
} catch (err: any) {
console.error('Fetch notifications error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchNotifications();
});
const getIcon = (type: string) => {
switch (type) {
case 'reel_like':
return heart;
case 'reel_comment':
return chatbubble;
case 'match':
return people;
case 'message':
return chatbubble;
default:
return flash;
}
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
<IonButtons slot="start">
<IonButton
onClick={() => history.goBack()}
style={{ '--color': 'var(--color-text-primary)' }}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle>Notifications</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
{previewMode && (
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '12px 20px 0',
}}
>
<Lottie
animationData={notificationsAnimation}
loop
style={{ width: 120 }}
/>
</div>
)}
<div style={{ padding: '16px 20px' }}>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : notifications.length === 0 ? (
<EmptyState
icon={notificationsOutline}
title="No notifications yet"
message="When you get likes, matches, or messages, they'll show up here."
ctaText="Go discover"
onCtaClick={() => history.push('/icebreaker')}
/>
) : (
<div
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
>
{notifications.map((n) => (
<div
key={n.id}
onClick={() => {
if (previewMode && !n.read_at) {
setNotifications((prev) =>
prev.map((item) =>
item.id === n.id
? { ...item, read_at: new Date().toISOString() }
: item
)
);
}
if (n.deep_link) history.push(n.deep_link);
}}
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
padding: '16px',
display: 'flex',
alignItems: 'center',
gap: '12px',
boxShadow: 'var(--shadow-card)',
opacity: n.read_at ? 0.7 : 1,
position: 'relative',
}}
>
<div
style={{
background: 'var(--color-accent-soft)',
width: '44px',
height: '44px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={getIcon(n.type)}
style={{
color: 'var(--ion-color-primary)',
fontSize: '20px',
}}
/>
</div>
<div style={{ flex: 1 }}>
<div
style={{
fontSize: '15px',
fontWeight: 700,
color: 'var(--color-text-primary)',
marginBottom: '2px',
}}
>
{n.title}
</div>
<div
style={{
fontSize: '13px',
color: 'var(--color-text-secondary)',
lineHeight: 1.4,
}}
>
{n.body}
</div>
<div
style={{
fontSize: '11px',
color: 'var(--color-text-tertiary)',
marginTop: '4px',
fontWeight: 600,
}}
>
{formatTime(n.created_at)}
</div>
</div>
{!n.read_at && (
<div
style={{
width: '8px',
height: '8px',
background: 'var(--ion-color-primary)',
borderRadius: '50%',
}}
/>
)}
</div>
))}
</div>
)}
</div>
</IonContent>
</IonPage>
);
};
export default NotificationsPage;
-318
View File
@@ -1,318 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonInput,
IonItem,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonSelect,
IonSelectOption,
IonTextarea,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import Avatar from '../components/Avatar';
const OnboardingProfilePage: React.FC = () => {
const history = useHistory();
const { user, refreshProfile } = useAuth();
const [fullName, setFullName] = useState('');
const [birthDate, setBirthDate] = useState('');
const [bio, setBio] = useState('');
const [gender, setGender] = useState('');
const [relationshipGoal, setRelationshipGoal] = useState('');
const [avatarPath, setAvatarPath] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
if (!user) history.replace('/auth');
});
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !user) return;
setUploading(true);
try {
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Math.random()}.${fileExt}`;
const filePath = `avatars/${fileName}`;
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(filePath, file);
if (uploadError) throw uploadError;
setAvatarPath(filePath);
} catch (err: any) {
showError(err.message);
} finally {
setUploading(false);
}
};
const handleComplete = async () => {
if (!fullName || !birthDate || !gender || !relationshipGoal || !user) {
showError('Please fill in all required fields');
return;
}
setLoading(true);
try {
const { error } = await supabase.from('profiles').upsert({
id: user.id,
full_name: fullName,
birth_date: birthDate,
bio,
gender,
relationship_goal: relationshipGoal,
avatar_path: avatarPath,
onboarding_complete: true,
});
if (error) throw error;
await refreshProfile();
history.push('/icebreaker');
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
const goals = [
{ label: 'Long-term partner', value: 'long_term' },
{ label: 'Long-term, but short-term OK', value: 'long_term_short_ok' },
{ label: 'Short-term, but long-term OK', value: 'short_term_long_ok' },
{ label: 'Short-term fun', value: 'short_term' },
{ label: 'New friends', value: 'friends' },
{ label: 'Still figuring it out', value: 'figuring_it_out' },
];
return (
<IonPage>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
<div
style={{
padding:
'calc(var(--ion-safe-area-top) + 40px) 20px calc(var(--ion-safe-area-bottom) + 40px)',
}}
>
<div style={{ marginBottom: '32px', textAlign: 'center' }}>
<h1
style={{
fontSize: '28px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 8px',
}}
>
Create your profile
</h1>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: 0,
}}
>
Let others know who you are.
</p>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBottom: '32px',
}}
>
<div style={{ position: 'relative' }}>
<Avatar
src={avatarPath}
name={fullName}
size={120}
style={{ border: '4px solid #ffffff' }}
/>
<label
style={{
position: 'absolute',
bottom: 0,
right: 0,
background: 'var(--ion-color-primary)',
color: '#ffffff',
width: '36px',
height: '36px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
border: '3px solid #ffffff',
}}
>
{uploading ? <IonSpinner name="crescent" /> : '+'}
<input
type="file"
accept="image/*"
onChange={handleFileUpload}
hidden
disabled={uploading}
/>
</label>
</div>
</div>
<div
style={{
background: 'var(--color-surface)',
padding: '24px',
borderRadius: 'var(--radius-card)',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="Full Name"
labelPlacement="floating"
value={fullName}
onIonInput={(e) => setFullName(e.detail.value!)}
required
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="Birth Date"
labelPlacement="floating"
type="date"
value={birthDate}
onIonInput={(e) => setBirthDate(e.detail.value!)}
required
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Gender"
labelPlacement="floating"
value={gender}
onIonChange={(e) => setGender(e.detail.value)}
interface="action-sheet"
>
<IonSelectOption value="woman">Woman</IonSelectOption>
<IonSelectOption value="man">Man</IonSelectOption>
<IonSelectOption value="nonbinary">Non-binary</IonSelectOption>
<IonSelectOption value="other">Other</IonSelectOption>
<IonSelectOption value="prefer_not_to_say">
Prefer not to say
</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="What are you looking for?"
labelPlacement="floating"
value={relationshipGoal}
onIonChange={(e) => setRelationshipGoal(e.detail.value)}
interface="action-sheet"
>
{goals.map((g) => (
<IonSelectOption key={g.value} value={g.value}>
{g.label}
</IonSelectOption>
))}
</IonSelect>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonTextarea
label="Bio"
labelPlacement="floating"
value={bio}
onIonInput={(e) => setBio(e.detail.value!)}
placeholder="Write something about yourself..."
rows={4}
/>
</IonItem>
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
}}
>
{error}
</div>
)}
<IonButton
expand="block"
disabled={loading || uploading}
onClick={handleComplete}
style={{
'--border-radius': 'var(--radius-pill)',
height: '48px',
marginTop: '8px',
}}
>
{loading ? <IonSpinner name="crescent" /> : 'Complete Profile'}
</IonButton>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default OnboardingProfilePage;
-300
View File
@@ -1,300 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import {
chevronBackOutline,
checkmarkCircle,
cardOutline,
infiniteOutline,
eyeOutline,
heartOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { formatCurrency } from '../utils/format';
import { createCheckout, pollPaymentStatus } from '../utils/payments';
const PremiumPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<
'idle' | 'opening' | 'pending' | 'paid' | 'failed'
>('idle');
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const handleSubscribe = async () => {
setLoading(true);
setStatus('opening');
try {
const { checkoutUrl, paymentId } = await createCheckout(9.99, 'premium');
// Phase 1 stub behavior: simulate payment by following stub return
window.open(checkoutUrl, '_blank');
setStatus('pending');
const result = await pollPaymentStatus(paymentId);
if (result === 'paid') {
setStatus('paid');
setTimeout(() => history.goBack(), 2000);
} else {
setStatus('failed');
}
} catch (err) {
console.error('Payment error:', err);
setStatus('failed');
} finally {
setLoading(false);
}
};
const benefits = [
{
icon: infiniteOutline,
title: 'Unlimited Swipes',
desc: 'Swipe as much as you want.',
},
{
icon: eyeOutline,
title: 'See Who Likes You',
desc: 'Check out your admirers instantly.',
},
{
icon: heartOutline,
title: '5 Super Likes / Day',
desc: 'Stand out from the crowd.',
},
{
icon: checkmarkCircle,
title: 'Verified Badge',
desc: 'Get more trust and matches.',
},
];
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
<IonButtons slot="start">
<IonButton
onClick={() => history.goBack()}
style={{ '--color': 'var(--color-text-primary)' }}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle>InSpot Premium</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
<div style={{ padding: '24px 20px' }}>
{/* Pricing Card */}
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
padding: '32px 24px',
textAlign: 'center',
boxShadow: 'var(--shadow-card)',
marginBottom: '32px',
border: '2px solid var(--ion-color-primary)',
}}
>
<div
style={{
fontSize: '15px',
fontWeight: 800,
color: 'var(--ion-color-primary)',
marginBottom: '8px',
textTransform: 'uppercase',
}}
>
Full Access
</div>
<div
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'center',
gap: '4px',
marginBottom: '8px',
}}
>
<span
style={{
fontSize: '40px',
fontWeight: 800,
color: 'var(--color-text-primary)',
}}
>
$9.99
</span>
<span
style={{
fontSize: '15px',
color: 'var(--color-text-tertiary)',
fontWeight: 600,
}}
>
/ month
</span>
</div>
<p
style={{
fontSize: '13px',
color: 'var(--color-text-secondary)',
margin: 0,
}}
>
Cancel anytime. No hidden fees.
</p>
</div>
<h3
style={{
fontSize: '18px',
fontWeight: 800,
color: 'var(--color-text-primary)',
marginBottom: '20px',
}}
>
What's included
</h3>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '20px',
marginBottom: '40px',
}}
>
{benefits.map((b, i) => (
<div
key={i}
style={{ display: 'flex', alignItems: 'center', gap: '16px' }}
>
<div
style={{
background: 'var(--color-accent-soft)',
width: '44px',
height: '44px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={b.icon}
style={{
color: 'var(--ion-color-primary)',
fontSize: '22px',
}}
/>
</div>
<div>
<div
style={{
fontSize: '15px',
fontWeight: 700,
color: 'var(--color-text-primary)',
}}
>
{b.title}
</div>
<div
style={{
fontSize: '13px',
color: 'var(--color-text-secondary)',
}}
>
{b.desc}
</div>
</div>
</div>
))}
</div>
{status === 'paid' && (
<div
style={{
background: 'rgba(22, 163, 74, 0.08)',
color: 'var(--ion-color-success)',
padding: '16px',
borderRadius: 'var(--radius-card)',
textAlign: 'center',
marginBottom: '24px',
fontWeight: 700,
}}
>
Payment successful! Welcome to Premium.
</div>
)}
{status === 'failed' && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '16px',
borderRadius: 'var(--radius-card)',
textAlign: 'center',
marginBottom: '24px',
fontWeight: 700,
}}
>
Payment failed or cancelled. Please try again.
</div>
)}
</div>
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: '#ffffff',
padding: '16px 20px calc(16px + var(--ion-safe-area-bottom))',
borderTop: '1px solid rgba(76, 5, 25, 0.05)',
}}
>
<IonButton
expand="block"
disabled={loading || status === 'paid'}
onClick={handleSubscribe}
style={{
'--border-radius': 'var(--radius-pill)',
height: '52px',
fontSize: '16px',
fontWeight: 700,
}}
>
{loading ? <IonSpinner name="crescent" /> : 'Get Premium Now'}
</IonButton>
</div>
</IonContent>
</IonPage>
);
};
export default PremiumPage;
-497
View File
@@ -1,497 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonIcon,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonAlert,
IonItem,
IonLabel,
IonList,
} from '@ionic/react';
import {
settingsOutline,
notificationsOutline,
checkmarkCircle,
chevronForwardOutline,
cardOutline,
shieldCheckmarkOutline,
logOutOutline,
trashOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import Avatar from '../components/Avatar';
import { getStorageUrl } from '../utils/storage';
import { previewCurrentUser, previewReels } from '../utils/previewData';
import { isPreviewMode, disablePreviewMode } from '../utils/previewMode';
const ProfilePage: React.FC = () => {
const history = useHistory();
const { user, profile, signOut } = useAuth();
const previewMode = isPreviewMode();
const displayProfile = previewMode && !profile ? previewCurrentUser : profile;
const [loading, setLoading] = useState(false);
const [reels, setReels] = useState<any[]>([]);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const fetchData = async () => {
if (previewMode && !user) {
setReels(previewReels);
return;
}
if (!user) return;
const { data } = await supabase
.from('reels')
.select('id, media_path')
.eq('author_id', user.id)
.limit(6);
setReels(data || []);
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchData();
});
const handleDeleteAccount = async () => {
setLoading(true);
try {
const { error } = await supabase.functions.invoke('delete-account', {
body: { confirm: true },
});
if (error) throw error;
await signOut();
history.replace('/auth');
} catch (err: any) {
console.error('Delete error:', err);
} finally {
setLoading(false);
}
};
return (
<IonPage>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
<div
style={{
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 12px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h1
style={{
fontSize: '24px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: 0,
}}
>
Profile
</h1>
<div style={{ display: 'flex', gap: '12px' }}>
<IonButton
fill="clear"
onClick={() => history.push('/notifications')}
style={{
'--background': 'var(--color-surface)',
'--color': 'var(--color-text-primary)',
'--border-radius': 'var(--radius-control)',
width: '44px',
height: '44px',
margin: 0,
}}
>
<IonIcon
icon={notificationsOutline}
slot="icon-only"
style={{ fontSize: '20px' }}
/>
</IonButton>
<IonButton
fill="clear"
style={{
'--background': 'var(--color-surface)',
'--color': 'var(--color-text-primary)',
'--border-radius': 'var(--radius-control)',
width: '44px',
height: '44px',
margin: 0,
}}
>
<IonIcon
icon={settingsOutline}
slot="icon-only"
style={{ fontSize: '20px' }}
/>
</IonButton>
</div>
</div>
<div style={{ padding: '0 20px 24px' }}>
{/* Profile Card */}
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
padding: '24px',
textAlign: 'center',
boxShadow: 'var(--shadow-card)',
marginBottom: '24px',
}}
>
<div
style={{
position: 'relative',
display: 'inline-block',
marginBottom: '16px',
}}
>
<Avatar
src={displayProfile?.avatar_path}
name={displayProfile?.full_name}
size={96}
style={{ border: '4px solid var(--color-bg)' }}
/>
<div
style={{
position: 'absolute',
bottom: 0,
right: 0,
background: '#ffffff',
borderRadius: '50%',
padding: '2px',
}}
>
<IonIcon
icon={checkmarkCircle}
style={{
color: 'var(--ion-color-success)',
fontSize: '24px',
}}
/>
</div>
</div>
<h2
style={{
fontSize: '22px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 4px',
}}
>
{displayProfile?.full_name || 'InSpot User'}
</h2>
<div
style={{
display: 'inline-block',
background: 'var(--color-accent-soft)',
color: 'var(--ion-color-primary)',
padding: '4px 12px',
borderRadius: 'var(--radius-pill)',
fontSize: '12px',
fontWeight: 700,
marginBottom: '20px',
}}
>
Long-term partner
</div>
<IonButton
expand="block"
onClick={() => history.push('/profile/edit')}
style={{
'--border-radius': 'var(--radius-pill)',
height: '44px',
'--background': 'var(--color-surface-raised)',
'--color': 'var(--ion-color-primary)',
'--box-shadow': 'none',
}}
>
Edit Profile
</IonButton>
</div>
{/* Premium CTA */}
<div
onClick={() => history.push('/premium')}
style={{
background:
'linear-gradient(135deg, var(--ion-color-primary) 0%, var(--ion-color-primary-dark) 100%)',
borderRadius: 'var(--radius-card)',
padding: '20px',
color: '#ffffff',
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '24px',
cursor: 'pointer',
}}
>
<div
style={{
background: 'rgba(255,255,255,0.2)',
width: '48px',
height: '48px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon icon={cardOutline} style={{ fontSize: '24px' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '15px', fontWeight: 800 }}>
InSpot Premium
</div>
<div style={{ fontSize: '12px', opacity: 0.8 }}>
Get unlimited swipes and see who likes you.
</div>
</div>
<IonIcon
icon={chevronForwardOutline}
style={{ fontSize: '20px' }}
/>
</div>
{/* My Reels */}
<div style={{ marginBottom: '24px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px',
}}
>
<h3
style={{
fontSize: '18px',
fontWeight: 800,
margin: 0,
color: 'var(--color-text-primary)',
}}
>
My Reels
</h3>
<IonButton
fill="clear"
style={{
fontSize: '13px',
'--color': 'var(--ion-color-primary)',
fontWeight: 700,
}}
>
See all
</IonButton>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '10px',
}}
>
{reels.map((reel) => (
<div
key={reel.id}
style={{
paddingBottom: '133%',
background: 'var(--color-surface-raised)',
borderRadius: '12px',
position: 'relative',
overflow: 'hidden',
}}
>
<img
src={
reel.image ||
getStorageUrl('reels', reel.media_path) ||
''
}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
alt="Reel"
/>
</div>
))}
<div
onClick={() => history.push('/camera')}
style={{
paddingBottom: '133%',
background: 'var(--color-surface-raised)',
borderRadius: '12px',
position: 'relative',
border: '2px dashed var(--ion-color-primary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon
icon={trashOutline}
style={{
fontSize: '24px',
color: 'var(--ion-color-primary)',
transform: 'rotate(45deg)',
}}
/>
</div>
</div>
</div>
</div>
{/* Settings List */}
<IonList
lines="none"
style={{ background: 'transparent', padding: 0 }}
>
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
padding: '8px',
boxShadow: 'var(--shadow-card)',
}}
>
<IonItem button detail={false} onClick={() => {}}>
<div
slot="start"
style={{
background: 'var(--color-accent-soft)',
width: '36px',
height: '36px',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginRight: '12px',
}}
>
<IonIcon
icon={shieldCheckmarkOutline}
color="primary"
style={{ fontSize: '20px' }}
/>
</div>
<IonLabel style={{ fontSize: '15px', fontWeight: 600 }}>
Privacy & Safety
</IonLabel>
<IonIcon
icon={chevronForwardOutline}
slot="end"
style={{
fontSize: '16px',
color: 'var(--color-text-tertiary)',
}}
/>
</IonItem>
<IonItem
button
detail={false}
onClick={() => {
localStorage.removeItem('inspot-preview-mode');
signOut();
history.replace('/auth');
}}
>
<div
slot="start"
style={{
background: 'var(--color-bg)',
width: '36px',
height: '36px',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginRight: '12px',
}}
>
<IonIcon
icon={logOutOutline}
color="dark"
style={{ fontSize: '20px' }}
/>
</div>
<IonLabel style={{ fontSize: '15px', fontWeight: 600 }}>
Sign Out
</IonLabel>
</IonItem>
<IonItem
button
detail={false}
onClick={() => setShowDeleteAlert(true)}
style={{ '--color': 'var(--ion-color-danger)' }}
>
<div
slot="start"
style={{
background: 'rgba(220, 38, 38, 0.08)',
width: '36px',
height: '36px',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginRight: '12px',
}}
>
<IonIcon
icon={trashOutline}
color="danger"
style={{ fontSize: '20px' }}
/>
</div>
<IonLabel style={{ fontSize: '15px', fontWeight: 600 }}>
Delete Account
</IonLabel>
</IonItem>
</div>
</IonList>
</div>
<IonAlert
isOpen={showDeleteAlert}
onDidDismiss={() => setShowDeleteAlert(false)}
header="Delete Account"
message="Are you sure you want to permanently delete your account? This action cannot be undone."
buttons={[
{ text: 'Cancel', role: 'cancel' },
{
text: 'Delete',
role: 'destructive',
handler: handleDeleteAccount,
},
]}
/>
</IonContent>
</IonPage>
);
};
export default ProfilePage;
-481
View File
@@ -1,481 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import {
IonPage,
IonContent,
IonIcon,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonItem,
IonInput,
} from '@ionic/react';
import {
closeOutline,
heartOutline,
heart,
chatbubbleOutline,
shareSocialOutline,
logoWhatsapp,
pinOutline,
send,
} from 'ionicons/icons';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { formatCompactNumber, formatDate } from '../utils/format';
import { getStorageUrl } from '../utils/storage';
import Avatar from '../components/Avatar';
import { previewPlaces, previewReels } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const ReelsPage: React.FC = () => {
const history = useHistory();
const { placeId } = useParams<{ placeId: string }>();
const { user } = useAuth();
const [reels, setReels] = useState<any[]>([]);
const [place, setPlace] = useState<any>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [loading, setLoading] = useState(true);
const [commentsOpen, setCommentsOpen] = useState(false);
const [commentDraft, setCommentDraft] = useState('');
const [comments, setComments] = useState<any[]>([]);
const [likedReels, setLikedReels] = useState<Set<string>>(new Set());
const previewMode = isPreviewMode();
const fetchReels = async () => {
setLoading(true);
if (previewMode) {
setPlace(previewPlaces.find((p) => p.id === placeId) || previewPlaces[0]);
setReels(previewReels);
setLoading(false);
return;
}
try {
const { data: placeData } = await supabase
.from('places')
.select('*')
.eq('id', placeId)
.single();
setPlace(placeData);
const { data, error } = await supabase
.from('reels')
.select(
`
*,
author:profiles(id, full_name, avatar_path)
`
)
.eq('place_id', placeId)
.order('created_at', { ascending: false });
if (error) throw error;
setReels(data || []);
if (user) {
const { data: likes } = await supabase
.from('reel_likes')
.select('reel_id')
.eq('user_id', user.id);
setLikedReels(new Set(likes?.map((l) => l.reel_id) || []));
}
} catch (err: any) {
console.error('Fetch reels error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchReels();
});
const handleLike = async (reelId: string) => {
if (!user && !previewMode) return;
const isLiked = likedReels.has(reelId);
// Optimistic update
const newLiked = new Set(likedReels);
if (isLiked) newLiked.delete(reelId);
else newLiked.add(reelId);
setLikedReels(newLiked);
if (previewMode) return;
try {
if (isLiked) {
await supabase
.from('reel_likes')
.delete()
.eq('reel_id', reelId)
.eq('user_id', user.id);
} else {
await supabase
.from('reel_likes')
.insert({ reel_id: reelId, user_id: user.id });
}
} catch (err) {
console.error('Like error:', err);
}
};
const currentReel = reels[activeIndex];
return (
<IonPage>
<IonContent fullscreen style={{ '--background': '#000000' }}>
<div
style={{
position: 'absolute',
top: 'calc(14px + var(--ion-safe-area-top))',
left: '20px',
zIndex: 10,
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<IonButton
fill="clear"
onClick={() => history.goBack()}
style={{
'--background': 'rgba(0,0,0,0.3)',
'--color': '#ffffff',
'--border-radius': '50%',
width: '40px',
height: '40px',
margin: 0,
}}
>
<IonIcon icon={closeOutline} slot="icon-only" />
</IonButton>
<span style={{ color: '#ffffff', fontWeight: 800, fontSize: '18px' }}>
Reels
</span>
</div>
{loading ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
background: '#000000',
}}
>
<IonSpinner name="crescent" color="primary" />
</div>
) : reels.length === 0 ? (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#ffffff',
padding: '20px',
textAlign: 'center',
}}
>
<p>No reels found for this place.</p>
<IonButton
expand="block"
fill="outline"
color="light"
onClick={() => history.push('/camera')}
>
Add the first reel
</IonButton>
</div>
) : (
<div
onClick={() =>
reels.length > 1 &&
setActiveIndex((prev) => (prev + 1) % reels.length)
}
style={{ height: '100%', width: '100%', position: 'relative' }}
>
{/* Media Area */}
<div style={{ width: '100%', height: '100%' }}>
{currentReel.media_type === 'video' ? (
<video
src={
currentReel.image ||
getStorageUrl('reels', currentReel.media_path) ||
''
}
autoPlay
loop
muted
playsInline
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<img
src={
currentReel.image ||
getStorageUrl('reels', currentReel.media_path) ||
''
}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
alt="Reel content"
/>
)}
</div>
{/* Action Rail */}
<div
style={{
position: 'absolute',
right: '12px',
bottom: '150px',
display: 'flex',
flexDirection: 'column',
gap: '20px',
alignItems: 'center',
}}
>
<div
onClick={(event) => {
event.stopPropagation();
handleLike(currentReel.id);
}}
style={{ textAlign: 'center' }}
>
<IonIcon
icon={likedReels.has(currentReel.id) ? heart : heartOutline}
style={{
fontSize: '32px',
color: likedReels.has(currentReel.id)
? 'var(--ion-color-primary)'
: '#ffffff',
}}
/>
<div
style={{
color: '#ffffff',
fontSize: '12px',
fontWeight: 700,
marginTop: '4px',
}}
>
{formatCompactNumber(currentReel.likes_count || 0)}
</div>
</div>
<div
onClick={(event) => {
event.stopPropagation();
setCommentsOpen(true);
}}
style={{ textAlign: 'center' }}
>
<IonIcon
icon={chatbubbleOutline}
style={{ fontSize: '30px', color: '#ffffff' }}
/>
<div
style={{
color: '#ffffff',
fontSize: '12px',
fontWeight: 700,
marginTop: '4px',
}}
>
{formatCompactNumber(currentReel.comments_count || 0)}
</div>
</div>
<IonIcon
icon={shareSocialOutline}
style={{ fontSize: '28px', color: '#ffffff' }}
/>
<IonIcon
icon={logoWhatsapp}
style={{ fontSize: '28px', color: '#ffffff' }}
/>
</div>
{/* Caption Overlay */}
<div
style={{
position: 'absolute',
bottom: 'calc(32px + var(--ion-safe-area-bottom))',
left: '20px',
right: '88px',
color: '#ffffff',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '16px',
}}
>
<div
style={{
background: 'var(--ion-color-primary)',
width: '36px',
height: '36px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon
icon={pinOutline}
style={{ fontSize: '18px', color: '#ffffff' }}
/>
</div>
<div>
<div style={{ fontSize: '13px', fontWeight: 800 }}>
{place?.name}
</div>
<div style={{ fontSize: '11px', opacity: 0.7 }}>
{formatDate(currentReel.created_at)}
</div>
</div>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
marginBottom: '8px',
}}
>
<Avatar
src={currentReel.author.avatar_path}
name={currentReel.author.full_name}
size={42}
/>
<span style={{ fontSize: '15px', fontWeight: 700 }}>
{currentReel.author.full_name}
</span>
</div>
<p
style={{
fontSize: '14px',
margin: 0,
opacity: 0.9,
lineHeight: 1.4,
}}
>
{currentReel.caption}
</p>
</div>
{/* Simple Next/Prev Controls for Phase 1 */}
{reels.length > 1 && (
<div
style={{
position: 'absolute',
bottom: '100px',
right: '12px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<IonButton
size="small"
fill="clear"
disabled={activeIndex === 0}
onClick={(event) => {
event.stopPropagation();
setActiveIndex((prev) => prev - 1);
}}
style={{ '--color': '#ffffff' }}
>
Prev
</IonButton>
<IonButton
size="small"
fill="clear"
disabled={activeIndex === reels.length - 1}
onClick={(event) => {
event.stopPropagation();
setActiveIndex((prev) => prev + 1);
}}
style={{ '--color': '#ffffff' }}
>
Next
</IonButton>
</div>
)}
</div>
)}
{/* Comments Sheet */}
<IonModal
isOpen={commentsOpen}
onDidDismiss={() => setCommentsOpen(false)}
initialBreakpoint={0.5}
breakpoints={[0, 0.5, 0.9]}
>
<IonHeader>
<IonToolbar>
<IonTitle>Comments</IonTitle>
<IonButtons slot="end">
<IonButton onClick={() => setCommentsOpen(false)}>
Close
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div
style={{
textAlign: 'center',
color: 'var(--color-text-secondary)',
padding: '20px',
}}
>
No comments yet. Be the first to comment!
</div>
{/* Input area fixed at bottom of modal content */}
<div
style={{
position: 'sticky',
bottom: 0,
background: '#ffffff',
padding: '12px 0',
}}
>
<div
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
>
<IonInput
value={commentDraft}
onIonInput={(e) => setCommentDraft(e.detail.value!)}
placeholder="Add a comment..."
style={{
background: 'var(--color-bg)',
'--border-radius': 'var(--radius-pill)',
'--padding-start': '16px',
}}
/>
<IonButton fill="clear" disabled={!commentDraft.trim()}>
<IonIcon icon={send} slot="icon-only" color="primary" />
</IonButton>
</div>
</div>
</IonContent>
</IonModal>
</IonContent>
</IonPage>
);
};
export default ReelsPage;
-200
View File
@@ -1,200 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonButton,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory, useLocation } from 'react-router-dom';
import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import OtpInput from '../components/OtpInput';
const VerifyEmailPage: React.FC = () => {
const history = useHistory();
const location = useLocation<{
email: string;
type: 'signup' | 'recovery';
}>();
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cooldown, setCooldown] = useState(60);
const email = location.state?.email;
const type = location.state?.type || 'signup';
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
if (!email) {
history.replace('/auth');
}
});
useEffect(() => {
if (cooldown > 0) {
const timer = setTimeout(() => setCooldown(cooldown - 1), 1000);
return () => clearTimeout(timer);
}
}, [cooldown]);
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleVerify = async () => {
if (code.length !== 6) return;
setLoading(true);
try {
let result;
if (type === 'recovery') {
const params: any = {
email,
token: code,
type: 'recovery',
};
result = await (supabase.auth as any).verifyOtp(params);
} else {
result = await supabase.auth.verifyOtp({
email,
token: code,
type: 'email',
});
}
if (result.error) throw result.error;
if (type === 'recovery') {
history.push('/new-password', { email });
} else {
history.push('/onboarding/profile');
}
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
const handleResend = async () => {
if (cooldown > 0) return;
setLoading(true);
try {
const resendParams: any = {
type: type === 'signup' ? 'email' : 'recovery',
email,
};
const { error } = await supabase.auth.resend(resendParams);
if (error) throw error;
setCooldown(60);
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background': 'var(--color-bg)',
}}
>
<div style={{ padding: 'calc(var(--ion-safe-area-top) + 40px) 20px' }}>
<div style={{ marginBottom: '32px' }}>
<h1
style={{
fontSize: '28px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 8px',
}}
>
Check your email
</h1>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: 0,
lineHeight: 1.5,
}}
>
We've sent a 6-digit verification code to{' '}
<span
style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}
>
{email}
</span>
.
</p>
</div>
<div
style={{
background: 'var(--color-surface)',
padding: '24px',
borderRadius: 'var(--radius-card)',
textAlign: 'center',
}}
>
<OtpInput value={code} onChange={setCode} disabled={loading} />
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
marginBottom: '20px',
}}
>
{error}
</div>
)}
<IonButton
expand="block"
disabled={loading || code.length !== 6}
onClick={handleVerify}
style={{
'--border-radius': 'var(--radius-pill)',
height: '48px',
}}
>
{loading ? <IonSpinner name="crescent" /> : 'Verify'}
</IonButton>
<button
onClick={handleResend}
disabled={loading || cooldown > 0}
style={{
background: 'none',
border: 'none',
color:
cooldown > 0
? 'var(--color-text-tertiary)'
: 'var(--ion-color-primary)',
fontSize: '14px',
fontWeight: 600,
marginTop: '20px',
cursor: cooldown > 0 ? 'default' : 'pointer',
}}
>
{cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'}
</button>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default VerifyEmailPage;
-7
View File
@@ -1,7 +0,0 @@
import { createClient } from '@supabase/supabase-js';
import type { Database } from './database.types';
export const supabase = createClient<Database>(
'https://yunudlmsairdxxlhfyci.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1bnVkbG1zYWlyZHh4bGhmeWNpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODExNDk2OTUsImV4cCI6MjA5NjcyNTY5NX0.WRYh_r5YuruaGrgfAR6Tg_CrSI1CPyYgpvrJQVTbyUE',
);
+115 -168
View File
@@ -1,205 +1,152 @@
/* InSpot exact theme: high-contrast black/white with Tinder Pink accents */ /* Ionic CSS Variables — customise to match your app's brand */
:root { :root {
--ion-color-primary: #fe3c72; --ion-color-primary: #5e6ad2;
--ion-color-primary-rgb: 254, 60, 114; --ion-color-primary-rgb: 94, 106, 210;
--ion-color-primary-contrast: #ffffff; --ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255, 255, 255; --ion-color-primary-contrast-rgb: 255, 255, 255;
--ion-color-primary-shade: #e03564; --ion-color-primary-shade: #535db9;
--ion-color-primary-tint: #fe507f; --ion-color-primary-tint: #6e79d7;
--ion-color-secondary: #ef4a75; --ion-color-secondary: #7a89f4;
--ion-color-secondary-rgb: 239, 74, 117; --ion-color-secondary-rgb: 122, 137, 244;
--ion-color-secondary-contrast: #ffffff; --ion-color-secondary-contrast: #ffffff;
--ion-color-secondary-contrast-rgb: 255, 255, 255; --ion-color-secondary-contrast-rgb: 255, 255, 255;
--ion-color-secondary-shade: #d24167; --ion-color-secondary-shade: #6b79d7;
--ion-color-secondary-tint: #f15c83; --ion-color-secondary-tint: #8895f5;
--ion-color-tertiary: #9e9e9e; --ion-color-tertiary: #8f78d8;
--ion-color-tertiary-rgb: 158, 158, 158; --ion-color-tertiary-rgb: 143, 120, 216;
--ion-color-tertiary-contrast: #000000; --ion-color-tertiary-contrast: #ffffff;
--ion-color-tertiary-contrast-rgb: 0, 0, 0; --ion-color-tertiary-contrast-rgb: 255, 255, 255;
--ion-color-tertiary-shade: #8b8b8b; --ion-color-tertiary-shade: #7e6ac0;
--ion-color-tertiary-tint: #a8a8a8; --ion-color-tertiary-tint: #9a86dc;
--ion-color-success: #22c55e; --ion-color-success: #3aa67a;
--ion-color-success-rgb: 34, 197, 94; --ion-color-success-rgb: 58, 166, 122;
--ion-color-success-contrast: #ffffff; --ion-color-success-contrast: #ffffff;
--ion-color-success-contrast-rgb: 255, 255, 255; --ion-color-success-contrast-rgb: 255, 255, 255;
--ion-color-success-shade: #1eae53; --ion-color-success-shade: #33926b;
--ion-color-success-tint: #38cb6e; --ion-color-success-tint: #4eb088;
--ion-color-warning: #ffcc00; --ion-color-warning: #dfb25a;
--ion-color-warning-rgb: 255, 204, 0; --ion-color-warning-rgb: 223, 178, 90;
--ion-color-warning-contrast: #000000; --ion-color-warning-contrast: #2d2520;
--ion-color-warning-contrast-rgb: 0, 0, 0; --ion-color-warning-contrast-rgb: 45, 37, 32;
--ion-color-warning-shade: #e0b400; --ion-color-warning-shade: #c49d4f;
--ion-color-warning-tint: #ffd11a; --ion-color-warning-tint: #e2bb6b;
--ion-color-danger: #ff4444; --ion-color-danger: #cf6679;
--ion-color-danger-rgb: 255, 68, 68; --ion-color-danger-rgb: 207, 102, 121;
--ion-color-danger-contrast: #ffffff; --ion-color-danger-contrast: #ffffff;
--ion-color-danger-contrast-rgb: 255, 255, 255; --ion-color-danger-contrast-rgb: 255, 255, 255;
--ion-color-danger-shade: #e03c3c; --ion-color-danger-shade: #b65a6a;
--ion-color-danger-tint: #ff5757; --ion-color-danger-tint: #d57587;
--ion-color-dark: #000000; --ion-color-dark: #1f2430;
--ion-color-dark-rgb: 0, 0, 0; --ion-color-dark-rgb: 31, 36, 48;
--ion-color-dark-contrast: #ffffff; --ion-color-dark-contrast: #ffffff;
--ion-color-dark-contrast-rgb: 255, 255, 255; --ion-color-dark-contrast-rgb: 255, 255, 255;
--ion-color-dark-shade: #000000; --ion-color-dark-shade: #1b202a;
--ion-color-dark-tint: #1a1a1a; --ion-color-dark-tint: #353946;
--ion-color-medium: #9e9e9e; --ion-color-medium: #7f8697;
--ion-color-medium-rgb: 158, 158, 158; --ion-color-medium-rgb: 127, 134, 151;
--ion-color-medium-contrast: #000000; --ion-color-medium-contrast: #ffffff;
--ion-color-medium-contrast-rgb: 0, 0, 0; --ion-color-medium-contrast-rgb: 255, 255, 255;
--ion-color-medium-shade: #8b8b8b; --ion-color-medium-shade: #707686;
--ion-color-medium-tint: #a8a8a8; --ion-color-medium-tint: #8c93a2;
--ion-color-light: #ffffff; --ion-color-light: #f3f5fb;
--ion-color-light-rgb: 255, 255, 255; --ion-color-light-rgb: 243, 245, 251;
--ion-color-light-contrast: #424242; --ion-color-light-contrast: #1f2430;
--ion-color-light-contrast-rgb: 66, 66, 66; --ion-color-light-contrast-rgb: 31, 36, 48;
--ion-color-light-shade: #e0e0e0; --ion-color-light-shade: #d6d8dd;
--ion-color-light-tint: #ffffff; --ion-color-light-tint: #f4f6fb;
--color-bg: #000000; --app-background: #ffffff;
--color-bg-light: #ffffff; --app-surface: #ffffff;
--color-surface: rgba(44, 53, 57, 0.5); --app-surface-secondary: #f6f7fb;
--color-surface-solid: #151a1d; --app-text-primary: #202532;
--color-surface-raised: rgba(44, 53, 57, 0.72); --app-text-secondary: rgba(32, 37, 50, 0.68);
--color-input: rgba(44, 53, 57, 0.5); --app-text-tertiary: rgba(32, 37, 50, 0.46);
--color-border: #333333; --app-accent-soft: rgba(94, 106, 210, 0.12);
--color-text-primary: #ffffff; --app-accent-softer: rgba(94, 106, 210, 0.08);
--color-text-secondary: #aaaaaa; --app-success-soft: rgba(58, 166, 122, 0.12);
--color-text-tertiary: #9e9e9e; --app-shadow: 0 18px 48px rgba(82, 95, 140, 0.08);
--color-inactive: #9e9e9e;
--color-light-text-primary: #424242;
--color-light-text-secondary: #555555;
--color-light-text-tertiary: #888888;
--color-light-card: #dcdcdc;
--color-alert: #ff4444;
--color-alert-light: #ff0000;
--color-accent-soft: rgba(254, 60, 114, 0.18);
--color-accent-softer: rgba(254, 60, 114, 0.1);
--radius-pill: 999px;
--radius-card: 20px;
--radius-control: 10px;
--shadow-card: none;
} }
body { body {
background: #000000; background: var(--app-background);
color: #ffffff; color: var(--app-text-primary);
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
sans-serif;
} }
.ion-color-primary { ion-content.home-content {
--ion-color-base: var(--ion-color-primary); --background: var(--app-background);
--ion-color-base-rgb: var(--ion-color-primary-rgb);
--ion-color-contrast: var(--ion-color-primary-contrast);
--ion-color-contrast-rgb: var(--ion-color-primary-contrast-rgb);
--ion-color-shade: var(--ion-color-primary-shade);
--ion-color-tint: var(--ion-color-primary-tint);
} }
.ion-color-secondary { .home-toolbar {
--ion-color-base: var(--ion-color-secondary); --background: rgba(255, 255, 255, 0.92);
--ion-color-base-rgb: var(--ion-color-secondary-rgb);
--ion-color-contrast: var(--ion-color-secondary-contrast);
--ion-color-contrast-rgb: var(--ion-color-secondary-contrast-rgb);
--ion-color-shade: var(--ion-color-secondary-shade);
--ion-color-tint: var(--ion-color-secondary-tint);
}
ion-content {
--background: #000000;
--color: #ffffff;
}
ion-tab-bar {
--background: #101014;
--border: none;
height: calc(64px + var(--ion-safe-area-bottom));
padding-bottom: var(--ion-safe-area-bottom);
box-shadow: none;
}
ion-tab-button {
--background: #101014;
--color: #9e9e9e;
--color-selected: #fe3c72;
}
ion-tab-button ion-icon {
font-size: 24px;
}
ion-tab-button ion-label {
font-size: 10px;
font-weight: 600;
margin-top: 4px;
}
ion-header ion-toolbar {
--background: #000000;
--border-width: 0; --border-width: 0;
--color: #ffffff; --border-color: transparent;
padding: 0 4px; --color: var(--app-text-primary);
box-shadow: none;
backdrop-filter: blur(18px);
} }
ion-header ion-title { ion-header.home-header::after {
display: none;
}
.home-shell {
padding: calc(88px + var(--ion-safe-area-top)) 24px
calc(32px + var(--ion-safe-area-bottom));
display: flex;
flex-direction: column;
}
.minimal-home-shell {
min-height: 100%;
justify-content: flex-start;
}
.minimal-welcome-card {
text-align: left;
display: flex;
flex-direction: column;
gap: 10px;
}
.subtle-welcome-card {
max-width: 420px;
margin: 0;
padding: 28px 24px;
background: var(--app-surface-secondary);
border-radius: 28px;
}
.minimal-welcome-card h1 {
margin: 0;
color: var(--app-text-primary);
font-size: 32px;
line-height: 1.12;
letter-spacing: -0.03em;
font-weight: 700; font-weight: 700;
font-size: 17px;
} }
ion-item { .minimal-subtitle {
--background: transparent; margin: 0;
--border-color: #333333; color: var(--app-text-secondary);
--color: #ffffff; font-size: 15px;
--highlight-color-focused: #fe3c72; line-height: 1.55;
} }
ion-list-header { @media (min-width: 680px) {
--color: #9e9e9e; .home-shell {
font-size: 11px; max-width: 720px;
font-weight: 700; margin: 0 auto;
text-transform: uppercase; }
letter-spacing: 0.05em;
}
ion-modal { .minimal-welcome-card h1 {
--background: #1b181f; font-size: 40px;
--border-radius: 20px 20px 0 0; }
--color: #ffffff;
}
ion-modal::part(content) {
background: #1b181f;
border-radius: 20px 20px 0 0;
}
ion-searchbar {
--background: rgba(44, 53, 57, 0.5);
--border-radius: 999px;
--box-shadow: none;
--color: #ffffff;
--placeholder-color: #aaaaaa;
--icon-color: #ffffff;
--clear-button-color: #9e9e9e;
padding-left: 0;
padding-right: 0;
}
ion-button {
--background: #fe3c72;
--background-activated: #ef4a75;
--background-focused: #ef4a75;
--background-hover: #ef4a75;
--color: #ffffff;
--box-shadow: none;
text-transform: none;
} }
-63
View File
@@ -1,63 +0,0 @@
export const formatAge = (birthDate: string | null) => {
if (!birthDate) return null;
const today = new Date();
const birth = new Date(birthDate);
let age = today.getFullYear() - birth.getFullYear();
const m = today.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) {
age--;
}
return age;
};
export const formatDistance = (meters: number | null) => {
if (meters === null) return null;
const miles = meters * 0.000621371;
if (miles < 0.1) return 'Very close';
return `${miles.toFixed(1)} mi`;
};
export const formatCompactNumber = (num: number) => {
return Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}).format(num);
};
export const formatDate = (date: string | null) => {
if (!date) return null;
return new Date(date).toLocaleDateString('en-US', {
weekday: 'short',
day: 'numeric',
month: 'short',
});
};
export const formatTime = (date: string | null) => {
if (!date) return null;
const d = new Date(date);
const now = new Date();
const diff = now.getTime() - d.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return d.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
} else if (days === 1) {
return 'Yesterday';
} else if (days < 7) {
return d.toLocaleDateString('en-US', { weekday: 'short' });
} else {
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
};
export const formatCurrency = (amount: number, currency = 'USD') => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
};
-55
View File
@@ -1,55 +0,0 @@
import { PushNotifications } from '@capacitor/push-notifications';
import type {
ActionPerformed,
PushNotificationSchema,
} from '@capacitor/push-notifications';
import { supabase } from '../supabase';
export async function setupPushNotifications(userId: string, history: any) {
const handleNotificationTap = (notification: PushNotificationSchema) => {
if (notification.data?.deep_link) {
history.push(notification.data.deep_link);
} else if (notification.data?.type === 'message') {
history.push(`/chat/${notification.data.conversation_id}`);
}
};
const launch = await (PushNotifications as any).getLaunchNotification();
if (launch) handleNotificationTap(launch.notification);
const status = await PushNotifications.checkPermissions();
if (status.receive === 'granted') {
await PushNotifications.register();
} else if (status.receive === 'prompt') {
const result = await PushNotifications.requestPermissions();
if (result.receive === 'granted') await PushNotifications.register();
}
PushNotifications.addListener('registration', async (token) => {
await supabase
.from('profiles')
.update({ fcm_token: token.value })
.eq('id', userId);
});
PushNotifications.addListener('registrationError', (err) => {
console.error('Push registration error', err);
});
PushNotifications.addListener(
'pushNotificationReceived',
async (notification: PushNotificationSchema) => {
console.log('Push received', notification);
}
);
PushNotifications.addListener(
'pushNotificationActionPerformed',
(action: ActionPerformed) => {
handleNotificationTap(action.notification);
}
);
await PushNotifications.removeAllDeliveredNotifications();
}
-31
View File
@@ -1,31 +0,0 @@
import { supabase } from '../supabase';
export async function createCheckout(amount: number, tier: 'premium') {
const { data, error } = await supabase.functions.invoke('create-payment', {
body: {
amount,
currency: 'USD',
tier,
description: `InSpot ${tier} Subscription`,
},
});
if (error) throw error;
return data as { checkoutUrl: string; paymentId: string };
}
export async function pollPaymentStatus(paymentId: string, maxAttempts = 20) {
for (let i = 0; i < maxAttempts; i++) {
const { data, error } = await supabase
.from('payments')
.select('status')
.eq('id', paymentId)
.single();
if (error) throw error;
if (data.status === 'paid' || data.status === 'failed') return data.status;
await new Promise((resolve) => setTimeout(resolve, 3000));
}
return 'pending';
}
-253
View File
@@ -1,253 +0,0 @@
import beautifyingImage from '../assets/images/beautifying.jpg';
import cooltoneImage from '../assets/images/cooltone.jpg';
import warmtoneImage from '../assets/images/warmtone.jpg';
import cubanaImage from '../assets/images/cubana.jpg';
import newscafeImage from '../assets/images/newscafe.jpg';
import inspotLogo from '../assets/images/inSpot.png';
export const previewProfiles = [
{
id: 'preview-profile-1',
full_name: 'Amahle',
birth_date: '1999-08-12',
age: 25,
distance: '1.4 km away',
bio: 'Coffee dates, live music, and spontaneous nights out.',
relationship_goal: 'Long-term partner',
interests: ['Live music', 'Foodie', 'Dancing'],
image: cooltoneImage,
images: [cooltoneImage, beautifyingImage, warmtoneImage],
avatar: cooltoneImage,
avatar_path: cooltoneImage,
},
{
id: 'preview-profile-2',
full_name: 'Lerato',
birth_date: '2000-03-04',
age: 24,
distance: '2.1 km away',
bio: 'New in the city. Show me your favourite hidden spot?',
relationship_goal: 'Dating',
interests: ['Travel', 'Sushi', 'Art'],
image: beautifyingImage,
images: [beautifyingImage, warmtoneImage, cooltoneImage],
avatar: beautifyingImage,
avatar_path: beautifyingImage,
},
{
id: 'preview-profile-3',
full_name: 'Maya',
birth_date: '1998-11-22',
age: 26,
distance: '3.6 km away',
bio: 'Good playlists, rooftop views, and warm conversations.',
relationship_goal: 'Friends first',
interests: ['Rooftops', 'R&B', 'Fitness'],
image: warmtoneImage,
images: [warmtoneImage, cooltoneImage, beautifyingImage],
avatar: warmtoneImage,
avatar_path: warmtoneImage,
},
];
export const previewPlaces = [
{
id: 'preview-place-1',
name: 'Cubana',
category: 'Cocktails, Lounge',
distance: '0.9 mi',
rating: 4.8,
review_count: 5800,
busyness: 'busy',
isBusy: 'Busy than usual',
mediaCount: 9,
image: cubanaImage,
image_path: cubanaImage,
},
{
id: 'preview-place-2',
name: 'News Cafe',
category: 'Cafe, Breakfast',
distance: '1.2 mi',
rating: 4.5,
review_count: 2200,
busyness: 'normal',
isBusy: 'Normal activity',
mediaCount: 6,
image: newscafeImage,
image_path: newscafeImage,
},
{
id: 'preview-place-3',
name: 'Glow Studio',
category: 'Beauty, Lifestyle',
distance: '1.8 mi',
rating: 4.7,
review_count: 1400,
busyness: 'busy',
isBusy: 'Busy than usual',
mediaCount: 4,
image: beautifyingImage,
image_path: beautifyingImage,
},
];
export const previewChats = [
{
id: 'preview-chat-1',
otherUser: previewProfiles[0],
lastMessage: 'Are you going to Cubana tonight?',
timestamp: new Date().toISOString(),
unread: 2,
},
{
id: 'preview-chat-2',
otherUser: previewProfiles[1],
lastMessage: 'That reel was actually so cute 😂',
timestamp: new Date(Date.now() - 1000 * 60 * 48).toISOString(),
unread: 0,
},
{
id: 'preview-chat-3',
otherUser: previewProfiles[2],
lastMessage: 'Let me know when you arrive.',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(),
unread: 1,
},
];
export const previewMessages: Record<string, any[]> = {
'preview-chat-1': [
{
id: 'preview-message-1',
conversation_id: 'preview-chat-1',
sender_id: 'preview-profile-1',
body: 'Hey, are you around Sandton tonight?',
created_at: new Date(Date.now() - 1000 * 60 * 15).toISOString(),
},
{
id: 'preview-message-2',
conversation_id: 'preview-chat-1',
sender_id: 'preview-me',
body: 'Yes! Thinking about checking out Cubana later.',
created_at: new Date(Date.now() - 1000 * 60 * 12).toISOString(),
},
{
id: 'preview-message-3',
conversation_id: 'preview-chat-1',
sender_id: 'preview-profile-1',
body: 'Perfect. It looks busy already, should be fun.',
created_at: new Date(Date.now() - 1000 * 60 * 8).toISOString(),
},
],
'preview-chat-2': [
{
id: 'preview-message-4',
conversation_id: 'preview-chat-2',
sender_id: 'preview-profile-2',
body: 'That reel was actually so cute 😂',
created_at: new Date(Date.now() - 1000 * 60 * 48).toISOString(),
},
{
id: 'preview-message-5',
conversation_id: 'preview-chat-2',
sender_id: 'preview-me',
body: 'Haha thank you. News Cafe made it look better.',
created_at: new Date(Date.now() - 1000 * 60 * 43).toISOString(),
},
],
'preview-chat-3': [
{
id: 'preview-message-6',
conversation_id: 'preview-chat-3',
sender_id: 'preview-profile-3',
body: 'Let me know when you arrive.',
created_at: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(),
},
],
};
export const previewReels = [
{
id: 'preview-reel-1',
place_id: 'preview-place-1',
caption: 'Friday night energy is already picking up.',
media_type: 'image',
image: cubanaImage,
media_path: cubanaImage,
likes_count: 5800,
comments_count: 183,
shares_count: 76,
created_at: new Date().toISOString(),
author: previewProfiles[0],
place: previewPlaces[0],
},
{
id: 'preview-reel-2',
place_id: 'preview-place-2',
caption: 'Best iced coffee before the day starts.',
media_type: 'image',
image: newscafeImage,
media_path: newscafeImage,
likes_count: 2400,
comments_count: 91,
shares_count: 32,
created_at: new Date(Date.now() - 1000 * 60 * 70).toISOString(),
author: previewProfiles[1],
place: previewPlaces[1],
},
{
id: 'preview-reel-3',
place_id: 'preview-place-3',
caption: 'A quick beauty reset before meeting friends.',
media_type: 'image',
image: beautifyingImage,
media_path: beautifyingImage,
likes_count: 1800,
comments_count: 44,
shares_count: 18,
created_at: new Date(Date.now() - 1000 * 60 * 180).toISOString(),
author: previewProfiles[2],
place: previewPlaces[2],
},
];
export const previewNotifications = [
{
id: 'preview-notification-1',
type: 'match',
title: 'New match',
body: 'You and Amahle liked each other. Say hi!',
deep_link: '/chats',
read_at: null,
created_at: new Date(Date.now() - 1000 * 60 * 5).toISOString(),
},
{
id: 'preview-notification-2',
type: 'message',
title: 'New message',
body: 'Lerato sent you a message about your reel.',
deep_link: '/chats',
read_at: null,
created_at: new Date(Date.now() - 1000 * 60 * 34).toISOString(),
},
{
id: 'preview-notification-3',
type: 'place_busy',
title: 'Cubana is live',
body: 'A place near you is busier than usual right now.',
deep_link: '/hangout',
read_at: new Date(Date.now() - 1000 * 60 * 60).toISOString(),
created_at: new Date(Date.now() - 1000 * 60 * 78).toISOString(),
},
];
export const previewCurrentUser = {
id: 'preview-me',
full_name: 'InSpot User',
relationship_goal: 'Long-term partner',
bio: 'Discovering people and places nearby.',
interests: ['Dating', 'Hangouts', 'Reels'],
avatar: inspotLogo,
avatar_path: inspotLogo,
};
-14
View File
@@ -1,14 +0,0 @@
export const isPreviewMode = () => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('inspot-preview-mode') !== 'false';
};
export const enablePreviewMode = () => {
if (typeof window === 'undefined') return;
localStorage.setItem('inspot-preview-mode', 'true');
};
export const disablePreviewMode = () => {
if (typeof window === 'undefined') return;
localStorage.setItem('inspot-preview-mode', 'false');
};
-43
View File
@@ -1,43 +0,0 @@
import { supabase } from '../supabase';
export const getStorageUrl = (bucket: string, path: string | null) => {
if (!path) return null;
if (
path.startsWith('http') ||
path.startsWith('/src/assets/') ||
path.startsWith('/assets/') ||
path.startsWith('data:')
) {
return path;
}
const { data } = supabase.storage.from(bucket).getPublicUrl(path);
return data.publicUrl;
};
export const getInitials = (name: string | null) => {
if (!name) return '?';
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.substring(0, 2);
};
export const getInitialsColor = (name: string | null) => {
if (!name) return '#fe3c72';
const colors = [
'#fe3c72',
'#ef4a75',
'#ff0000',
'#ff4444',
'#9E9E9E',
'#333333',
];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
};
+2 -22
View File
@@ -1795,7 +1795,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@supabase/supabase-js@npm:^2.108.1": "@supabase/supabase-js@npm:^2.0.0":
version: 2.108.1 version: 2.108.1
resolution: "@supabase/supabase-js@npm:2.108.1" resolution: "@supabase/supabase-js@npm:2.108.1"
dependencies: dependencies:
@@ -2040,7 +2040,7 @@ __metadata:
"@ionic/pwa-elements": "npm:^3.0.0" "@ionic/pwa-elements": "npm:^3.0.0"
"@ionic/react": "npm:8.8.7" "@ionic/react": "npm:8.8.7"
"@ionic/react-router": "npm:8.8.7" "@ionic/react-router": "npm:8.8.7"
"@supabase/supabase-js": "npm:^2.108.1" "@supabase/supabase-js": "npm:^2.0.0"
"@types/katex": "npm:^0.16.0" "@types/katex": "npm:^0.16.0"
"@types/react": "npm:^19.0.0" "@types/react": "npm:^19.0.0"
"@types/react-dom": "npm:^19.0.0" "@types/react-dom": "npm:^19.0.0"
@@ -2050,7 +2050,6 @@ __metadata:
firebase: "npm:^11.0.0" firebase: "npm:^11.0.0"
ionicons: "npm:^7.4.0" ionicons: "npm:^7.4.0"
katex: "npm:^0.16.0" katex: "npm:^0.16.0"
lottie-react: "npm:^2.4.1"
react: "npm:18.2.0" react: "npm:18.2.0"
react-dom: "npm:18.2.0" react-dom: "npm:18.2.0"
react-hook-form: "npm:^7.54.0" react-hook-form: "npm:^7.54.0"
@@ -2750,25 +2749,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lottie-react@npm:^2.4.1":
version: 2.4.1
resolution: "lottie-react@npm:2.4.1"
dependencies:
lottie-web: "npm:^5.10.2"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/7f6a0a2cf2af25deabe7b4d4c9917e47d12016673613c59dcdd8ef52e10e914dd0c1f71d4a275d56aaba03fe9d85b333087f7c8d40a8f967ece4a0444c7a3cdf
languageName: node
linkType: hard
"lottie-web@npm:^5.10.2":
version: 5.13.0
resolution: "lottie-web@npm:5.13.0"
checksum: 10c0/b463ad462169df3f7e04b7f3716274e269842554f1d72b8303ca7245628f2512982a59ae31698b3d57fbcda886228ae160fcb2aa518c6cecd8a95974db2458ee
languageName: node
linkType: hard
"lru-cache@npm:^11.0.0": "lru-cache@npm:^11.0.0":
version: 11.5.0 version: 11.5.0
resolution: "lru-cache@npm:11.5.0" resolution: "lru-cache@npm:11.5.0"