Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c279e86750 |
@@ -4,7 +4,7 @@ android {
|
||||
namespace = "io.ionic.starter"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.inspot.app"
|
||||
applicationId "com.habitmode.llemba.com"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -26,19 +22,7 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<meta-data
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 742 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 708 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 742 B After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
</resources>
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">InSpot</string>
|
||||
<string name="title_activity_main">InSpot</string>
|
||||
<string name="app_name">app</string>
|
||||
<string name="title_activity_main">app</string>
|
||||
<string name="package_name">io.ionic.starter</string>
|
||||
<string name="custom_url_scheme">io.ionic.starter</string>
|
||||
</resources>
|
||||
|
||||
@@ -15,4 +15,5 @@ ext {
|
||||
cordovaAndroidVersion = '14.0.1'
|
||||
rgcfaIncludeGoogle = true
|
||||
androidxCredentialsVersion = '1.3.0'
|
||||
javaVersion = JavaVersion.VERSION_17
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.inspot.app',
|
||||
appName: 'InSpot',
|
||||
appId: 'com.habitmode.llemba.com',
|
||||
appName: 'HabitMode',
|
||||
webDir: 'dist',
|
||||
plugins: {
|
||||
FirebaseAuthentication: {
|
||||
skipNativeAuth: false,
|
||||
providers: [],
|
||||
providers: ["google.com", "apple.com"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,6 +55,13 @@ workflows:
|
||||
if [ -n "$CM_DEBUG_KEYSTORE" ]; then
|
||||
echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12
|
||||
fi
|
||||
- name: Check native credentials
|
||||
script: |
|
||||
if [ -n "$VITE_GOOGLE_MAPS_KEY" ]; then
|
||||
echo "VITE_GOOGLE_MAPS_KEY: set (length=${#VITE_GOOGLE_MAPS_KEY}, ends …${VITE_GOOGLE_MAPS_KEY: -4})"
|
||||
else
|
||||
echo "WARNING: VITE_GOOGLE_MAPS_KEY not set — Google Maps will not render"
|
||||
fi
|
||||
- name: Build debug APK
|
||||
script: |
|
||||
cd android
|
||||
@@ -68,6 +75,21 @@ workflows:
|
||||
else
|
||||
./gradlew assembleDebug --no-daemon
|
||||
fi
|
||||
- name: Verify manifest substitution
|
||||
script: |
|
||||
MANIFEST="android/app/build/intermediates/merged_manifests/debug/AndroidManifest.xml"
|
||||
if [ -f "$MANIFEST" ]; then
|
||||
VAL=$(grep -A1 'geo.API_KEY' "$MANIFEST" | grep -o 'android:value="[^"]*"' | cut -d'"' -f2)
|
||||
if [ -z "$VAL" ]; then
|
||||
echo "WARNING: geo.API_KEY meta-data not found in merged manifest"
|
||||
elif [ "$VAL" = '${googleMapsApiKey}' ]; then
|
||||
echo "WARNING: geo.API_KEY placeholder was not substituted — key missing from build env"
|
||||
else
|
||||
echo "geo.API_KEY substituted OK (ends …${VAL: -4})"
|
||||
fi
|
||||
else
|
||||
echo "Merged manifest not found at $MANIFEST"
|
||||
fi
|
||||
artifacts:
|
||||
- android/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
|
||||
@@ -308,7 +308,7 @@
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.inspot.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -330,7 +330,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.inspot.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
<key>aps-environment</key>
|
||||
<string>production</string>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
@@ -13,6 +13,24 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
Messaging.messaging().apnsToken = deviceToken
|
||||
Messaging.messaging().token { token, error in
|
||||
@@ -27,40 +45,4 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
|
||||
}
|
||||
|
||||
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.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
// Called when the app was launched with a url. Feel free to add additional processing here,
|
||||
// but if you want the App API to support tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||
// Called when the app was launched with an activity, including Universal Links.
|
||||
// Feel free to add additional processing here, but if you want the App API to support
|
||||
// tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,122 +1,14 @@
|
||||
{
|
||||
"images": [
|
||||
"images" : [
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"scale": "1x",
|
||||
"size": "20x20",
|
||||
"filename": "icon-20.png"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
"filename" : "AppIcon-512@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "appsuite",
|
||||
"version": 1
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 480 B |
|
Before Width: | Height: | Size: 663 B |
|
Before Width: | Height: | Size: 979 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>InSpot</string>
|
||||
<string>HabitMode</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -50,32 +50,11 @@
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.inspot.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>com.inspot.app</string>
|
||||
<string>com.googleusercontent.apps.1097913965629-pf48952i0uv9shgvie3dq11s18sd0t2p</string>
|
||||
</array>
|
||||
</dict>
|
||||
</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>
|
||||
</plist>
|
||||
|
||||
@@ -20,15 +20,13 @@
|
||||
"@capacitor/keyboard": "8.0.3",
|
||||
"@capacitor/push-notifications": "^8.1.1",
|
||||
"@capacitor/status-bar": "8.0.2",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@ionic/pwa-elements": "^3.0.0",
|
||||
"@ionic/react": "8.8.7",
|
||||
"@ionic/react-router": "8.8.7",
|
||||
"@supabase/supabase-js": "^2.108.1",
|
||||
"@ionic/pwa-elements": "^3.4.0",
|
||||
"@ionic/react": "^8.5.0",
|
||||
"@ionic/react-router": "^8.5.0",
|
||||
"@supabase/supabase-js": "^2.0.0",
|
||||
"firebase": "^11.0.0",
|
||||
"ionicons": "^7.4.0",
|
||||
"katex": "^0.16.0",
|
||||
"lottie-react": "^2.4.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.54.0",
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route } from 'react-router-dom';
|
||||
import {
|
||||
IonApp,
|
||||
IonIcon,
|
||||
IonLabel,
|
||||
IonRouterOutlet,
|
||||
IonTabBar,
|
||||
IonTabButton,
|
||||
IonTabs,
|
||||
} from '@ionic/react';
|
||||
import { IonApp, IonRouterOutlet } 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/normalize.css';
|
||||
import '@ionic/react/css/structure.css';
|
||||
import '@ionic/react/css/typography.css';
|
||||
|
||||
/* Optional CSS utils that can be commented out */
|
||||
import '@ionic/react/css/padding.css';
|
||||
import '@ionic/react/css/float-elements.css';
|
||||
import '@ionic/react/css/text-alignment.css';
|
||||
@@ -31,122 +14,26 @@ import '@ionic/react/css/text-transformation.css';
|
||||
import '@ionic/react/css/flex-utils.css';
|
||||
import '@ionic/react/css/display.css';
|
||||
|
||||
/* Theme variables */
|
||||
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';
|
||||
import Home from './pages/Home';
|
||||
import Account from './pages/Account';
|
||||
import Auth from './pages/Auth';
|
||||
import Stats from './pages/Stats';
|
||||
import HabitDetail from './pages/HabitDetail';
|
||||
|
||||
/* Contexts & Components */
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import { isPreviewMode } from './utils/previewMode';
|
||||
|
||||
const App: React.FC = () => (
|
||||
const App = (): any => (
|
||||
<IonApp>
|
||||
<AuthProvider>
|
||||
<IonReactRouter>
|
||||
<IonRouterOutlet>
|
||||
{/* Auth Stack */}
|
||||
<Route path="/auth" component={AuthPage} exact />
|
||||
<Route path="/verify-email" component={VerifyEmailPage} exact />
|
||||
<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>
|
||||
<IonReactRouter>
|
||||
<IonRouterOutlet>
|
||||
<Route exact path="/auth" component={Auth} />
|
||||
<Route exact path="/home" component={Home} />
|
||||
<Route exact path="/stats" component={Stats} />
|
||||
<Route exact path="/habit/:id" component={HabitDetail} />
|
||||
<Route exact path="/account" component={Account} />
|
||||
<Route exact path="/" render={() => <Redirect to="/home" />} />
|
||||
</IonRouterOutlet>
|
||||
</IonReactRouter>
|
||||
</IonApp>
|
||||
);
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 13 KiB |
@@ -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":[]}
|
||||
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 771 KiB |
|
After Width: | Height: | Size: 752 KiB |
@@ -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;
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { IonProgressBar } from '@ionic/react';
|
||||
|
||||
interface CustomProgressBarProps {
|
||||
value: number; // 0 to 1
|
||||
label?: string;
|
||||
statusText?: string;
|
||||
color?: string; // Hex, rgb, or css variable
|
||||
showPercent?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const CustomProgressBar: React.FC<CustomProgressBarProps> = ({
|
||||
value,
|
||||
label,
|
||||
statusText,
|
||||
color = 'var(--ion-color-primary)',
|
||||
showPercent = true,
|
||||
style,
|
||||
}) => {
|
||||
const percentage = Math.round(Math.min(Math.max(value, 0), 1) * 100);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', ...style }}>
|
||||
{/* Label and Status */}
|
||||
{(label || statusText || showPercent) && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
fontSize: '0.8rem',
|
||||
marginBottom: '6px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--ion-color-medium)' }}>
|
||||
{label}{' '}
|
||||
{statusText && (
|
||||
<span style={{ fontWeight: 'normal', opacity: 0.85 }}>
|
||||
• {statusText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showPercent && (
|
||||
<span
|
||||
style={{
|
||||
color: color,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
fontWeight: '700',
|
||||
}}
|
||||
>
|
||||
{percentage}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Track */}
|
||||
<IonProgressBar
|
||||
value={value}
|
||||
style={
|
||||
{
|
||||
height: '6px',
|
||||
'--background': 'rgba(148, 163, 184, 0.1)',
|
||||
'--progress-background': color,
|
||||
borderRadius: '10px',
|
||||
transition: 'value 0.3s ease',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomProgressBar;
|
||||
@@ -1,82 +1,30 @@
|
||||
import React from 'react';
|
||||
import { IonButton, IonIcon } from '@ionic/react';
|
||||
import { sparklesOutline } from 'ionicons/icons';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: string;
|
||||
title: string;
|
||||
message: string;
|
||||
ctaText?: string;
|
||||
onCtaClick?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
actionLabel: string;
|
||||
onAction: () => void;
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
ctaText,
|
||||
onCtaClick,
|
||||
style = {},
|
||||
actionLabel,
|
||||
onAction,
|
||||
}) => {
|
||||
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 className="empty-state-card home-empty-state-card">
|
||||
<div className="empty-state-icon">
|
||||
<IonIcon icon={sparklesOutline} />
|
||||
</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>
|
||||
)}
|
||||
<h2>{title}</h2>
|
||||
<p>{message}</p>
|
||||
<IonButton className="home-empty-state-button" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</IonButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { IonButton, IonIcon, IonSpinner } from '@ionic/react';
|
||||
import {
|
||||
checkmarkOutline,
|
||||
ellipseOutline,
|
||||
flameOutline,
|
||||
trashOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import type { Tables } from '../database.types';
|
||||
|
||||
interface HabitCardProps {
|
||||
habit: Tables<'habits'>;
|
||||
completedToday: boolean;
|
||||
currentStreak: number;
|
||||
completionRate: number;
|
||||
onToggle: () => void;
|
||||
onDelete: () => void;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
const HabitCard: React.FC<HabitCardProps> = ({
|
||||
habit,
|
||||
completedToday,
|
||||
currentStreak,
|
||||
completionRate,
|
||||
onToggle,
|
||||
onDelete,
|
||||
busy,
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`habit-card habit-card--editorial ${completedToday ? 'is-complete' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => history.push(`/habit/${habit.id}`)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
history.push(`/habit/${habit.id}`);
|
||||
}
|
||||
}}
|
||||
aria-label={`View details for ${habit.name}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`habit-check-button ${completedToday ? 'is-complete' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
disabled={busy}
|
||||
aria-label={
|
||||
completedToday
|
||||
? `Mark ${habit.name} as not done`
|
||||
: `Mark ${habit.name} as done`
|
||||
}
|
||||
>
|
||||
{busy ? (
|
||||
<IonSpinner name="crescent" />
|
||||
) : completedToday ? (
|
||||
<IonIcon icon={checkmarkOutline} />
|
||||
) : (
|
||||
<IonIcon icon={ellipseOutline} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="habit-card-main">
|
||||
<div className="habit-copy">
|
||||
<div className="habit-copy-topline">
|
||||
<h3>{habit.name}</h3>
|
||||
<span
|
||||
className="habit-color-pill"
|
||||
style={{ background: habit.color }}
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
{habit.target_description ||
|
||||
'Show up today and keep the chain alive.'}
|
||||
</p>
|
||||
<div className="habit-metrics-row">
|
||||
<span>
|
||||
<IonIcon icon={flameOutline} /> {currentStreak} streak
|
||||
</span>
|
||||
<span>{completionRate}% this week</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IonButton
|
||||
className="habit-delete-button"
|
||||
fill="clear"
|
||||
color="medium"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
<IonIcon icon={trashOutline} />
|
||||
</IonButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HabitCard;
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonModal,
|
||||
IonTextarea,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from '@ionic/react';
|
||||
|
||||
interface HabitFormModalProps {
|
||||
isOpen: boolean;
|
||||
onDismiss: () => void;
|
||||
onSubmit: (values: {
|
||||
name: string;
|
||||
targetDescription: string;
|
||||
color: string;
|
||||
}) => Promise<void>;
|
||||
initialValues?: {
|
||||
name: string;
|
||||
targetDescription: string;
|
||||
color: string;
|
||||
};
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#7c3aed',
|
||||
'#0f766e',
|
||||
'#ea580c',
|
||||
'#dc2626',
|
||||
'#2563eb',
|
||||
'#059669',
|
||||
];
|
||||
|
||||
const HabitFormModal: React.FC<HabitFormModalProps> = ({
|
||||
isOpen,
|
||||
onDismiss,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
}) => {
|
||||
const isEditing = !!initialValues;
|
||||
const [name, setName] = useState(initialValues?.name ?? '');
|
||||
const [targetDescription, setTargetDescription] = useState(
|
||||
initialValues?.targetDescription ?? ''
|
||||
);
|
||||
const [color, setColor] = useState(initialValues?.color ?? COLORS[0]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() => name.trim().length > 0 && name.trim().length <= 80,
|
||||
[name]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName(initialValues?.name ?? '');
|
||||
setTargetDescription(initialValues?.targetDescription ?? '');
|
||||
setColor(initialValues?.color ?? COLORS[0]);
|
||||
setError('');
|
||||
}
|
||||
}, [isOpen, initialValues]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
name: name.trim(),
|
||||
targetDescription: targetDescription.trim(),
|
||||
color,
|
||||
});
|
||||
setName('');
|
||||
setTargetDescription('');
|
||||
setColor(COLORS[0]);
|
||||
onDismiss();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save habit.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonModal isOpen={isOpen} onDidDismiss={onDismiss}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonTitle>{isEditing ? 'Edit Habit' : 'New Habit'}</IonTitle>
|
||||
<IonButtons slot="end">
|
||||
<IonButton onClick={onDismiss}>Close</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent className="ion-padding">
|
||||
<div className="habit-form-grid">
|
||||
<IonItem lines="none" className="auth-input-item">
|
||||
<IonInput
|
||||
label="Habit name"
|
||||
labelPlacement="stacked"
|
||||
value={name}
|
||||
maxlength={80}
|
||||
placeholder="Drink water"
|
||||
onIonInput={(e) => setName(e.detail.value ?? '')}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<IonItem lines="none" className="auth-input-item">
|
||||
<IonTextarea
|
||||
label="Target or note"
|
||||
labelPlacement="stacked"
|
||||
value={targetDescription}
|
||||
placeholder="8 glasses, 20 minutes, 30 pushups..."
|
||||
autoGrow
|
||||
onIonInput={(e) => setTargetDescription(e.detail.value ?? '')}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<div>
|
||||
<IonLabel className="color-label">Color</IonLabel>
|
||||
<div className="habit-color-row">
|
||||
{COLORS.map((swatch) => (
|
||||
<button
|
||||
key={swatch}
|
||||
type="button"
|
||||
className={`habit-color-dot ${color === swatch ? 'selected' : ''}`}
|
||||
style={{ background: swatch }}
|
||||
onClick={() => setColor(swatch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="auth-feedback error-text">{error}</p> : null}
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || saving}
|
||||
>
|
||||
{saving ? 'Saving…' : isEditing ? 'Save Changes' : 'Create Habit'}
|
||||
</IonButton>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default HabitFormModal;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonButtons,
|
||||
IonBackButton,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
showBackButton?: boolean;
|
||||
defaultBackHref?: string;
|
||||
endActions?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
toolbarStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
title,
|
||||
showBackButton = false,
|
||||
defaultBackHref = '/home',
|
||||
endActions,
|
||||
style,
|
||||
toolbarStyle,
|
||||
}) => {
|
||||
// Use Ionic lifecycle hook to set status bar style automatically for pages using PageHeader
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark); // Dark text/icons for light-colored headers
|
||||
});
|
||||
|
||||
return (
|
||||
<IonHeader className="ion-no-border" style={style}>
|
||||
<IonToolbar
|
||||
style={
|
||||
{
|
||||
'--background': '#ffffff',
|
||||
'--color': 'var(--ion-color-dark)',
|
||||
'--border-width': '0 0 1px 0',
|
||||
'--border-color': 'rgba(0, 0, 0, 0.05)',
|
||||
...toolbarStyle,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{showBackButton && (
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref={defaultBackHref} />
|
||||
</IonButtons>
|
||||
)}
|
||||
|
||||
<IonTitle
|
||||
style={{
|
||||
fontWeight: '800',
|
||||
fontSize: '1.1rem',
|
||||
letterSpacing: '-0.3px',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</IonTitle>
|
||||
|
||||
{endActions && <IonButtons slot="end">{endActions}</IonButtons>}
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageHeader;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
|
||||
interface RevisionInstrumentCardProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: string;
|
||||
iconColor: string;
|
||||
iconBgColor: string;
|
||||
onClick: () => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const RevisionInstrumentCard: React.FC<RevisionInstrumentCardProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
iconColor,
|
||||
iconBgColor,
|
||||
onClick,
|
||||
style,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
border: 'none',
|
||||
borderRadius: '14px',
|
||||
padding: '14px 10px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: 'none',
|
||||
transition: 'transform 0.15s ease',
|
||||
...style,
|
||||
}}
|
||||
className="ion-activatable"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '10px',
|
||||
background: iconBgColor,
|
||||
color: iconColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 8px auto',
|
||||
}}
|
||||
>
|
||||
<IonIcon icon={icon} style={{ fontSize: '18px' }} />
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: '700',
|
||||
color: 'var(--ion-color-dark)',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.65rem',
|
||||
color: 'var(--ion-color-medium)',
|
||||
display: 'block',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevisionInstrumentCard;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { IonProgressBar } from '@ionic/react';
|
||||
|
||||
interface StatsHabitCardProps {
|
||||
name: string;
|
||||
color: string;
|
||||
completedCount: number;
|
||||
totalDays: number;
|
||||
streak: number;
|
||||
}
|
||||
|
||||
const StatsHabitCard: React.FC<StatsHabitCardProps> = ({
|
||||
name,
|
||||
color,
|
||||
completedCount,
|
||||
totalDays,
|
||||
streak,
|
||||
}) => {
|
||||
const ratio = totalDays > 0 ? completedCount / totalDays : 0;
|
||||
const percentage = Math.round(ratio * 100);
|
||||
|
||||
return (
|
||||
<div className="stats-card">
|
||||
<div className="stats-card-header">
|
||||
<div>
|
||||
<h3>{name}</h3>
|
||||
<p>
|
||||
{completedCount} of {totalDays} days completed
|
||||
</p>
|
||||
</div>
|
||||
<div className="stats-streak-badge" style={{ color }}>
|
||||
{streak}🔥
|
||||
</div>
|
||||
</div>
|
||||
<IonProgressBar
|
||||
value={ratio}
|
||||
style={{ '--progress-background': color } as React.CSSProperties}
|
||||
/>
|
||||
<div className="stats-card-footer">
|
||||
<span>{percentage}% consistency</span>
|
||||
<span>Current streak {streak}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsHabitCard;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -14,609 +14,124 @@ export type Database = {
|
||||
}
|
||||
public: {
|
||||
Tables: {
|
||||
check_ins: {
|
||||
habit_completions: {
|
||||
Row: {
|
||||
checked_in_at: string | null
|
||||
expires_at: string
|
||||
completed_on: string
|
||||
created_at: string
|
||||
habit_id: string
|
||||
id: string
|
||||
place_id: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
checked_in_at?: string | null
|
||||
expires_at: string
|
||||
completed_on: string
|
||||
created_at?: string
|
||||
habit_id: string
|
||||
id?: string
|
||||
place_id: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
checked_in_at?: string | null
|
||||
expires_at?: string
|
||||
completed_on?: string
|
||||
created_at?: string
|
||||
habit_id?: string
|
||||
id?: string
|
||||
place_id?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "check_ins_place_id_fkey"
|
||||
columns: ["place_id"]
|
||||
foreignKeyName: "habit_completions_habit_id_fkey"
|
||||
columns: ["habit_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "places"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "check_ins_user_id_fkey"
|
||||
columns: ["user_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedRelation: "habits"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
conversations: {
|
||||
habits: {
|
||||
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
|
||||
color: string
|
||||
created_at: string
|
||||
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
|
||||
name: string
|
||||
target_description: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
avatar_path?: string | null
|
||||
bio?: string | null
|
||||
birth_date?: string | null
|
||||
city?: string | null
|
||||
created_at?: string | null
|
||||
color?: string
|
||||
created_at?: string
|
||||
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
|
||||
id?: string
|
||||
name: string
|
||||
target_description?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
avatar_path?: string | null
|
||||
bio?: string | null
|
||||
birth_date?: string | null
|
||||
city?: string | null
|
||||
created_at?: string | null
|
||||
color?: string
|
||||
created_at?: string
|
||||
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
|
||||
name?: string
|
||||
target_description?: string | null
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
swipes: {
|
||||
notes: {
|
||||
Row: {
|
||||
action: string
|
||||
created_at: string | null
|
||||
content: string
|
||||
created_at: string
|
||||
id: string
|
||||
swiped_id: string
|
||||
swiper_id: string
|
||||
is_favorite: boolean
|
||||
title: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
action: string
|
||||
created_at?: string | null
|
||||
content: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
swiped_id: string
|
||||
swiper_id: string
|
||||
is_favorite?: boolean
|
||||
title: string
|
||||
user_id?: string
|
||||
}
|
||||
Update: {
|
||||
action?: string
|
||||
created_at?: string | null
|
||||
content?: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
swiped_id?: string
|
||||
swiper_id?: string
|
||||
is_favorite?: boolean
|
||||
title?: string
|
||||
user_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"]
|
||||
},
|
||||
]
|
||||
Relationships: []
|
||||
}
|
||||
todos: {
|
||||
Row: {
|
||||
created_at: string
|
||||
description: string | null
|
||||
due_date: string | null
|
||||
id: string
|
||||
is_completed: boolean
|
||||
title: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
description?: string | null
|
||||
due_date?: string | null
|
||||
id?: string
|
||||
is_completed?: boolean
|
||||
title: string
|
||||
user_id?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
description?: string | null
|
||||
due_date?: string | null
|
||||
id?: string
|
||||
is_completed?: boolean
|
||||
title?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Capacitor } from '@capacitor/core';
|
||||
import App from './App';
|
||||
|
||||
// ── Ionic initialisation ──────────────────────────────────────────────────────
|
||||
// ?mode=md|ios overrides (used by Appcakes preview); otherwise follow the platform.
|
||||
// ?mode=md|ios overrides (used by AppSuite preview); otherwise follow the platform.
|
||||
const _urlMode = new URLSearchParams(window.location.search).get('mode');
|
||||
const _platform = Capacitor.getPlatform(); // 'ios' | 'android' | 'web'
|
||||
setupIonicReact({
|
||||
@@ -13,7 +13,7 @@ setupIonicReact({
|
||||
});
|
||||
|
||||
// Derive the studio parent origin dynamically so this works in both dev
|
||||
// (parent at localhost:3000) and production (parent at studio.appcakes.dev).
|
||||
// (parent at localhost:3000) and production (parent at appcakes.qqura.com).
|
||||
const studioOrigin = (() => {
|
||||
try {
|
||||
if (document.referrer) return new URL(document.referrer).origin;
|
||||
@@ -49,7 +49,7 @@ if (import.meta.hot) {
|
||||
}
|
||||
|
||||
// ── Session bridge ────────────────────────────────────────────────────────────
|
||||
// Post auth session to the Appcakes parent frame so the AI can test auth-protected
|
||||
// Post auth session to the AppSuite parent frame so the AI can test auth-protected
|
||||
// Edge Functions. Uses import.meta.glob so Vite never errors if supabase.ts is absent.
|
||||
(async () => {
|
||||
const mods = import.meta.glob('./supabase.ts', { eager: false });
|
||||
@@ -73,17 +73,12 @@ if (import.meta.hot) {
|
||||
})();
|
||||
|
||||
// ── Runtime error reporting ───────────────────────────────────────────────────
|
||||
// Forward uncaught errors to the Appcakes dev server so the AI can read them.
|
||||
// Extract projectId from the preview URL (/preview/{projectId}/) so errors are
|
||||
// scoped per-project on the server rather than mixed into a global queue.
|
||||
const _previewMatch = window.location.pathname.match(/\/preview\/([^/]+)/);
|
||||
const _projectId = _previewMatch?.[1] ?? null;
|
||||
|
||||
// Forward uncaught errors to the AppSuite dev server so the AI can read them.
|
||||
function reportError(message: string, stack?: string) {
|
||||
fetch(`${studioOrigin}/api/agent/runtime-error`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, stack, projectId: _projectId }),
|
||||
body: JSON.stringify({ message, stack }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
IonText,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
waterOutline,
|
||||
fitnessOutline,
|
||||
shieldCheckmarkOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Redirect as RedirectRR, useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import {
|
||||
Style,
|
||||
setStatusBarBackground,
|
||||
setStatusBarStyle,
|
||||
} from '../utils/statusBar';
|
||||
import { useIonViewWillEnter } from '@ionic/react';
|
||||
import runnerDrinkingWaterImage from '../assets/runner-drinking-water-photorealistic.png';
|
||||
|
||||
const RedirectAny = RedirectRR as any;
|
||||
|
||||
const Account: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
const [email, setEmail] = useState<string | null>(null);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [signOutError, setSignOutError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
if (!mounted) return;
|
||||
setEmail(data.session?.user.email ?? null);
|
||||
setSessionChecked(true);
|
||||
});
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
if (!mounted) return;
|
||||
setEmail(session?.user.email ?? null);
|
||||
setSessionChecked(true);
|
||||
|
||||
if (!session) {
|
||||
history.replace('/auth');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [history]);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setSignOutError('');
|
||||
setSigningOut(true);
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
|
||||
if (error) {
|
||||
setSignOutError(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
await FirebaseAuthentication.signOut();
|
||||
} catch {
|
||||
// Ignore if no native Google session exists.
|
||||
}
|
||||
}
|
||||
|
||||
setEmail(null);
|
||||
history.replace('/auth');
|
||||
} catch (error) {
|
||||
setSignOutError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unable to sign out right now.',
|
||||
);
|
||||
} finally {
|
||||
setSigningOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (sessionChecked && !email) {
|
||||
return <RedirectAny to="/auth" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonTitle>Account</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent fullscreen>
|
||||
<div className="dashboard-shell ion-padding account-shell">
|
||||
<section className="account-hero-card">
|
||||
<div className="account-hero-image-wrap">
|
||||
<img
|
||||
src={runnerDrinkingWaterImage}
|
||||
alt="Runner drinking water after a run"
|
||||
className="account-hero-image"
|
||||
/>
|
||||
<div className="account-hero-overlay" />
|
||||
</div>
|
||||
|
||||
<div className="account-hero-content">
|
||||
<span className="eyebrow">Recovery & account</span>
|
||||
<h1>{email ?? 'Loading...'}</h1>
|
||||
<p>
|
||||
Stay hydrated, stay consistent, and keep your habit history
|
||||
safely synced across sessions.
|
||||
</p>
|
||||
|
||||
<div className="account-highlight-row">
|
||||
<div className="account-highlight-chip">
|
||||
<IonIcon icon={waterOutline} />
|
||||
<span>Recovery mindset</span>
|
||||
</div>
|
||||
<div className="account-highlight-chip subtle">
|
||||
<IonIcon icon={fitnessOutline} />
|
||||
<span>Built for routines</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="stats-card account-details-card">
|
||||
<IonItem lines="none" className="account-info-item">
|
||||
<IonLabel>
|
||||
<h2>Email</h2>
|
||||
<p>{email ?? '—'}</p>
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
|
||||
<div className="account-trust-row">
|
||||
<div className="account-trust-icon">
|
||||
<IonIcon icon={shieldCheckmarkOutline} />
|
||||
</div>
|
||||
<div>
|
||||
<h3>Your data is synced</h3>
|
||||
<p>
|
||||
Sign in to keep your streaks and progress available whenever
|
||||
you come back.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
className="account-primary-button"
|
||||
routerLink="/home"
|
||||
>
|
||||
Back to Habits
|
||||
</IonButton>
|
||||
{signOutError ? (
|
||||
<IonText color="danger">
|
||||
<p className="auth-feedback">{signOutError}</p>
|
||||
</IonText>
|
||||
) : null}
|
||||
<div></div>
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill="outline"
|
||||
color="danger"
|
||||
className="account-secondary-button"
|
||||
onClick={handleSignOut}
|
||||
disabled={signingOut}
|
||||
>
|
||||
{signingOut ? 'Signing Out…' : 'Sign Out'}
|
||||
</IonButton>
|
||||
</div>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Account;
|
||||
@@ -0,0 +1,398 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonCard,
|
||||
IonCardContent,
|
||||
IonContent,
|
||||
IonIcon,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonPage,
|
||||
IonSegment,
|
||||
IonSegmentButton,
|
||||
IonText,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Redirect, useHistory } from 'react-router-dom';
|
||||
import {
|
||||
Style,
|
||||
setStatusBarBackground,
|
||||
setStatusBarStyle,
|
||||
} from '../utils/statusBar';
|
||||
import { supabase } from '../supabase';
|
||||
|
||||
const Auth: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const [mode, setMode] = useState<'login' | 'signup'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [googleLoading, setGoogleLoading] = useState(false);
|
||||
const [appleLoading, setAppleLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [info, setInfo] = useState('');
|
||||
const [verificationMessage, setVerificationMessage] = useState('');
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
const [hasSession, setHasSession] = useState(false);
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setError(msg);
|
||||
setTimeout(() => {
|
||||
setError((current) => (current === msg ? '' : current));
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
setStatusBarBackground('#f6f7fb');
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
if (!mounted) return;
|
||||
setHasSession(Boolean(data.session));
|
||||
setSessionChecked(true);
|
||||
});
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
if (!mounted) return;
|
||||
setHasSession(Boolean(session));
|
||||
setSessionChecked(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const formValid = useMemo(() => {
|
||||
return email.trim().length > 3 && password.length >= 6;
|
||||
}, [email, password]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
setInfo('');
|
||||
setVerificationMessage('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (mode === 'login') {
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
|
||||
if (signInError) {
|
||||
showError(signInError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
history.replace('/home');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error: signUpError } = await supabase.auth.signUp({
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
|
||||
if (signUpError) {
|
||||
showError(signUpError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.session) {
|
||||
history.replace('/home');
|
||||
return;
|
||||
}
|
||||
|
||||
setVerificationMessage(
|
||||
'Account created! Check your email for a verification link.'
|
||||
);
|
||||
setInfo('Once verified, come back and sign in.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
setError('');
|
||||
setInfo('');
|
||||
setVerificationMessage('');
|
||||
setGoogleLoading(true);
|
||||
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
showError(
|
||||
'Google Sign-In is only available in the native app. Use email to sign in here.'
|
||||
);
|
||||
setGoogleLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await FirebaseAuthentication.signInWithGoogle();
|
||||
const idToken = result.credential?.idToken;
|
||||
|
||||
if (!idToken) {
|
||||
showError('Google sign-in did not return an ID token.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: googleAuthError } = await supabase.auth.signInWithIdToken({
|
||||
provider: 'google',
|
||||
token: idToken,
|
||||
});
|
||||
|
||||
if (googleAuthError) {
|
||||
showError(googleAuthError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
history.replace('/home');
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Google sign-in failed.';
|
||||
showError(message);
|
||||
} finally {
|
||||
setGoogleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppleLogin = async () => {
|
||||
setError('');
|
||||
setInfo('');
|
||||
setVerificationMessage('');
|
||||
setAppleLoading(true);
|
||||
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
showError(
|
||||
'Apple Sign-In is only available in the native app. Use email to sign in here.'
|
||||
);
|
||||
setAppleLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await FirebaseAuthentication.signInWithApple({
|
||||
skipNativeAuth: true,
|
||||
});
|
||||
const idToken = result.credential?.idToken;
|
||||
const rawNonce = result.credential?.nonce;
|
||||
|
||||
if (!idToken) {
|
||||
showError('Apple sign-in did not return an ID token.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: appleAuthError } = await supabase.auth.signInWithIdToken({
|
||||
provider: 'apple',
|
||||
token: idToken,
|
||||
nonce: rawNonce || undefined,
|
||||
});
|
||||
|
||||
if (appleAuthError) {
|
||||
showError(appleAuthError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
history.replace('/home');
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Apple sign-in failed.';
|
||||
showError(message);
|
||||
} finally {
|
||||
setAppleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (sessionChecked && hasSession) {
|
||||
return <Redirect to="/home" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent fullscreen style={{ '--background': '#f6f7fb' }}>
|
||||
<div className="auth-shell">
|
||||
<div className="auth-hero">
|
||||
<span className="eyebrow">Protect your streaks</span>
|
||||
<h1>Build habits that actually stick.</h1>
|
||||
<p>
|
||||
Track daily wins, recover faster from misses, and see which habits
|
||||
are getting real momentum.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<IonCard className="auth-card">
|
||||
<IonCardContent>
|
||||
<IonSegment
|
||||
value={mode}
|
||||
onIonChange={(e) =>
|
||||
setMode(e.detail.value as 'login' | 'signup')
|
||||
}
|
||||
>
|
||||
<IonSegmentButton value="login">
|
||||
<IonLabel>Login</IonLabel>
|
||||
</IonSegmentButton>
|
||||
<IonSegmentButton value="signup">
|
||||
<IonLabel>Sign Up</IonLabel>
|
||||
</IonSegmentButton>
|
||||
</IonSegment>
|
||||
|
||||
{verificationMessage ? (
|
||||
<div className="auth-message-block">
|
||||
<h2>Check your inbox</h2>
|
||||
<p>{verificationMessage}</p>
|
||||
<IonText color="medium">
|
||||
<p>{info}</p>
|
||||
</IonText>
|
||||
<IonButton fill="clear" onClick={() => setMode('login')}>
|
||||
Back to Login
|
||||
</IonButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="auth-form">
|
||||
<IonItem lines="none" className="auth-input-item">
|
||||
<IonInput
|
||||
type="email"
|
||||
label="Email"
|
||||
labelPlacement="stacked"
|
||||
value={email}
|
||||
placeholder="you@example.com"
|
||||
onIonInput={(e) => setEmail(e.detail.value ?? '')}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<IonItem lines="none" className="auth-input-item">
|
||||
<IonInput
|
||||
type="password"
|
||||
label="Password"
|
||||
labelPlacement="stacked"
|
||||
value={password}
|
||||
placeholder="At least 6 characters"
|
||||
onIonInput={(e) => setPassword(e.detail.value ?? '')}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
{error ? (
|
||||
<IonText color="danger">
|
||||
<p className="auth-feedback">{error}</p>
|
||||
</IonText>
|
||||
) : null}
|
||||
|
||||
{info ? (
|
||||
<IonText color="medium">
|
||||
<p className="auth-feedback">{info}</p>
|
||||
</IonText>
|
||||
) : null}
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
disabled={
|
||||
!formValid || loading || googleLoading || appleLoading
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{loading
|
||||
? 'Please wait…'
|
||||
: mode === 'login'
|
||||
? 'Log In'
|
||||
: 'Create Account'}
|
||||
</IonButton>
|
||||
|
||||
{mode === 'login' ? (
|
||||
<>
|
||||
<div className="auth-divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill="outline"
|
||||
className="google-auth-button"
|
||||
disabled={loading || googleLoading || appleLoading}
|
||||
onClick={handleGoogleLogin}
|
||||
>
|
||||
<span
|
||||
slot="start"
|
||||
className="google-svg-wrap"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
marginRight: '8px',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{googleLoading ? 'Connecting…' : 'Continue with Google'}
|
||||
</IonButton>
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill="outline"
|
||||
className="apple-auth-button"
|
||||
disabled={loading || googleLoading || appleLoading}
|
||||
onClick={handleAppleLogin}
|
||||
>
|
||||
<span
|
||||
slot="start"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
marginRight: '8px',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<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>
|
||||
</span>
|
||||
{appleLoading ? 'Connecting…' : 'Continue with Apple'}
|
||||
</IonButton>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Auth;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,623 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
IonAlert,
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonPage,
|
||||
IonSkeletonText,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { IonBackButton } from '@ionic/react';
|
||||
import {
|
||||
checkmarkOutline,
|
||||
createOutline,
|
||||
ellipseOutline,
|
||||
flameOutline,
|
||||
sparklesOutline,
|
||||
trashOutline,
|
||||
trendingUpOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { Redirect, useHistory, useParams } from 'react-router-dom';
|
||||
import type { Tables } from '../database.types';
|
||||
import { supabase } from '../supabase';
|
||||
import HabitFormModal from '../components/HabitFormModal';
|
||||
import {
|
||||
Style,
|
||||
setStatusBarBackground,
|
||||
setStatusBarStyle,
|
||||
} from '../utils/statusBar';
|
||||
|
||||
interface HabitWithCompletions extends Tables<'habits'> {
|
||||
completions: Tables<'habit_completions'>[];
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||
|
||||
const calculateStreak = (dates: string[]) => {
|
||||
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
|
||||
let streak = 0;
|
||||
let cursor = new Date();
|
||||
|
||||
if (!unique.includes(formatDate(cursor))) {
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
|
||||
while (unique.includes(formatDate(cursor))) {
|
||||
streak += 1;
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
|
||||
return streak;
|
||||
};
|
||||
|
||||
const HabitDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const history = useHistory();
|
||||
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [habit, setHabit] = useState<HabitWithCompletions | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
|
||||
const loadHabit = useCallback(async () => {
|
||||
setError('');
|
||||
|
||||
const {
|
||||
data: { session },
|
||||
error: sessionError,
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (sessionError) {
|
||||
setError(sessionError.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
setUserId(null);
|
||||
setSessionChecked(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setUserId(session.user.id);
|
||||
setSessionChecked(true);
|
||||
|
||||
const { data, error: habitError } = await supabase
|
||||
.from('habits')
|
||||
.select('*, completions:habit_completions(*)')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (habitError) {
|
||||
if (habitError.code === 'PGRST116') {
|
||||
setHabit(null);
|
||||
} else {
|
||||
setError(habitError.message);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setHabit(data as HabitWithCompletions);
|
||||
setLoading(false);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadHabit();
|
||||
}, [loadHabit]);
|
||||
|
||||
const completionDates = useMemo(
|
||||
() => habit?.completions.map((c) => c.completed_on) ?? [],
|
||||
[habit],
|
||||
);
|
||||
|
||||
const today = formatDate(new Date());
|
||||
const doneToday = completionDates.includes(today);
|
||||
|
||||
const currentStreak = useMemo(
|
||||
() => calculateStreak(completionDates),
|
||||
[completionDates],
|
||||
);
|
||||
|
||||
const totalCompletions = completionDates.length;
|
||||
|
||||
// Calculate longest streak
|
||||
const longestStreak = useMemo(() => {
|
||||
const unique = Array.from(new Set(completionDates)).sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
if (unique.length === 0) return 0;
|
||||
let maxStreak = 1;
|
||||
let current = 1;
|
||||
for (let i = 1; i < unique.length; i++) {
|
||||
const prev = new Date(unique[i - 1]);
|
||||
const curr = new Date(unique[i]);
|
||||
const diffDays =
|
||||
(curr.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (diffDays === 1) {
|
||||
current += 1;
|
||||
maxStreak = Math.max(maxStreak, current);
|
||||
} else {
|
||||
current = 1;
|
||||
}
|
||||
}
|
||||
return maxStreak;
|
||||
}, [completionDates]);
|
||||
|
||||
// Days since habit creation (or first completion, whichever is earlier)
|
||||
const daysSinceStart = useMemo(() => {
|
||||
const startDate = habit
|
||||
? new Date(
|
||||
Math.min(
|
||||
new Date(habit.created_at).getTime(),
|
||||
...completionDates.map((d) => new Date(d).getTime()),
|
||||
Date.now(),
|
||||
),
|
||||
)
|
||||
: new Date();
|
||||
const diff = Math.ceil(
|
||||
(Date.now() - startDate.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
return Math.max(diff, 1);
|
||||
}, [habit, completionDates]);
|
||||
|
||||
const consistencyRate = Math.round((totalCompletions / daysSinceStart) * 100);
|
||||
|
||||
// 30-day heatmap data
|
||||
const heatmapDays = useMemo(() => {
|
||||
return Array.from({ length: 30 }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (29 - i));
|
||||
const key = formatDate(date);
|
||||
return {
|
||||
key,
|
||||
completed: completionDates.includes(key),
|
||||
isToday: key === today,
|
||||
};
|
||||
});
|
||||
}, [completionDates, today]);
|
||||
|
||||
const toggleToday = async () => {
|
||||
if (!userId || !habit) return;
|
||||
setBusy(true);
|
||||
|
||||
if (doneToday) {
|
||||
const existing = habit.completions.find((c) => c.completed_on === today);
|
||||
if (existing) {
|
||||
const { error: deleteError } = await supabase
|
||||
.from('habit_completions')
|
||||
.delete()
|
||||
.eq('id', existing.id);
|
||||
if (deleteError) setError(deleteError.message);
|
||||
}
|
||||
} else {
|
||||
const { error: insertError } = await supabase
|
||||
.from('habit_completions')
|
||||
.insert({
|
||||
habit_id: habit.id,
|
||||
user_id: userId,
|
||||
completed_on: today,
|
||||
});
|
||||
if (insertError) setError(insertError.message);
|
||||
}
|
||||
|
||||
await loadHabit();
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const updateHabit = async (values: {
|
||||
name: string;
|
||||
targetDescription: string;
|
||||
color: string;
|
||||
}) => {
|
||||
if (!userId || !habit) throw new Error('Habit not found.');
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('habits')
|
||||
.update({
|
||||
name: values.name,
|
||||
target_description: values.targetDescription || null,
|
||||
color: values.color,
|
||||
})
|
||||
.eq('id', habit.id);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
await loadHabit();
|
||||
};
|
||||
|
||||
const deleteHabit = async () => {
|
||||
if (!habit) return;
|
||||
setBusy(true);
|
||||
const { error: deleteError } = await supabase
|
||||
.from('habits')
|
||||
.delete()
|
||||
.eq('id', habit.id);
|
||||
if (deleteError) {
|
||||
setError(deleteError.message);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
history.push('/home');
|
||||
};
|
||||
|
||||
if (sessionChecked && !userId) {
|
||||
return <Redirect to="/auth" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref="/home" />
|
||||
</IonButtons>
|
||||
<IonTitle>{habit?.name ?? 'Habit'}</IonTitle>
|
||||
<IonButtons slot="end">
|
||||
{habit && (
|
||||
<>
|
||||
<IonButton fill="clear" onClick={() => setShowEditModal(true)}>
|
||||
<IonIcon icon={createOutline} />
|
||||
</IonButton>
|
||||
<IonButton
|
||||
fill="clear"
|
||||
color="danger"
|
||||
onClick={() => setShowDeleteAlert(true)}
|
||||
>
|
||||
<IonIcon icon={trashOutline} />
|
||||
</IonButton>
|
||||
</>
|
||||
)}
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent fullscreen>
|
||||
<div className="dashboard-shell ion-padding">
|
||||
{/* Loading state */}
|
||||
{loading ? (
|
||||
<>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '60%', height: '24px', borderRadius: '8px' }}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '120px',
|
||||
borderRadius: '24px',
|
||||
}}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '160px',
|
||||
borderRadius: '24px',
|
||||
}}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100px',
|
||||
borderRadius: '24px',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : error ? (
|
||||
/* Error state */
|
||||
<div className="inline-message error-card">
|
||||
<h3>Something went wrong</h3>
|
||||
<p>{error}</p>
|
||||
<IonButton onClick={loadHabit}>Try Again</IonButton>
|
||||
</div>
|
||||
) : !habit ? (
|
||||
/* Not found state */
|
||||
<div className="inline-message">
|
||||
<h3>Habit not found</h3>
|
||||
<p>
|
||||
This habit may have been deleted or you don't have access
|
||||
to it.
|
||||
</p>
|
||||
<IonButton routerLink="/home">Back to habits</IonButton>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Habit info card */}
|
||||
<section className="hero-card compact">
|
||||
<div className="habit-copy-topline">
|
||||
<span
|
||||
className="habit-color-pill"
|
||||
style={{ background: habit.color }}
|
||||
/>
|
||||
<span className="eyebrow">Your routine</span>
|
||||
</div>
|
||||
<h1>{habit.name}</h1>
|
||||
<p>
|
||||
{habit.target_description ||
|
||||
'Show up today and keep the chain alive.'}
|
||||
</p>
|
||||
|
||||
{/* Today toggle */}
|
||||
<div style={{ marginTop: '18px' }}>
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill={doneToday ? 'solid' : 'outline'}
|
||||
onClick={toggleToday}
|
||||
disabled={busy}
|
||||
style={
|
||||
doneToday
|
||||
? ({
|
||||
'--background': habit.color,
|
||||
'--background-hover': habit.color,
|
||||
'--background-activated': habit.color,
|
||||
'--border-radius': '18px',
|
||||
minHeight: '50px',
|
||||
} as any)
|
||||
: ({
|
||||
'--border-color': habit.color,
|
||||
'--color': habit.color,
|
||||
'--border-radius': '18px',
|
||||
minHeight: '50px',
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<IonIcon
|
||||
icon={doneToday ? checkmarkOutline : ellipseOutline}
|
||||
slot="start"
|
||||
/>
|
||||
{doneToday ? 'Completed today' : "Mark today's habit done"}
|
||||
</IonButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* All-time stats */}
|
||||
<section>
|
||||
<p className="home-section-label">All-time stats</p>
|
||||
<div className="stack-list">
|
||||
{/* Stats row */}
|
||||
<div
|
||||
className="hero-card compact"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: '12px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '1.8rem',
|
||||
fontWeight: 800,
|
||||
color: habit.color,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{totalCompletions}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--ion-color-medium)',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
total done
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '1.8rem',
|
||||
fontWeight: 800,
|
||||
color: habit.color,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{longestStreak}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--ion-color-medium)',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
best streak
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '1.8rem',
|
||||
fontWeight: 800,
|
||||
color: habit.color,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{consistencyRate}%
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--ion-color-medium)',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
consistency
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current streak highlight */}
|
||||
<div
|
||||
className="hero-card compact"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '14px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '16px',
|
||||
background: `${habit.color}18`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={flameOutline}
|
||||
style={{ color: habit.color, fontSize: '1.3rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 700,
|
||||
color: 'var(--ion-color-dark)',
|
||||
}}
|
||||
>
|
||||
{currentStreak} day{currentStreak === 1 ? '' : 's'} and
|
||||
counting
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: '4px 0 0',
|
||||
color: 'var(--ion-color-medium)',
|
||||
fontSize: '0.84rem',
|
||||
}}
|
||||
>
|
||||
{currentStreak > 0
|
||||
? `You've shown up every day since ${(() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - currentStreak + 1);
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
})()}.`
|
||||
: "Start today and build a streak you're proud of."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 30-day heatmap */}
|
||||
<section>
|
||||
<p className="home-section-label">Last 30 days</p>
|
||||
<div className="hero-card compact">
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{heatmapDays.map((day) => (
|
||||
<div
|
||||
key={day.key}
|
||||
title={
|
||||
day.isToday
|
||||
? 'Today'
|
||||
: new Date(day.key).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
style={{
|
||||
aspectRatio: '1',
|
||||
borderRadius: '6px',
|
||||
background: day.completed
|
||||
? habit.color
|
||||
: `${habit.color}14`,
|
||||
border: day.isToday
|
||||
? `2px solid ${habit.color}`
|
||||
: '2px solid transparent',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: '12px',
|
||||
fontSize: '0.74rem',
|
||||
color: 'var(--ion-color-medium)',
|
||||
}}
|
||||
>
|
||||
<span>30 days ago</span>
|
||||
<span>
|
||||
<IonIcon
|
||||
icon={trendingUpOutline}
|
||||
style={{ verticalAlign: 'middle', marginRight: '4px' }}
|
||||
/>
|
||||
{heatmapDays.filter((d) => d.completed).length} of 30 days
|
||||
</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Edit modal */}
|
||||
{showEditModal && (
|
||||
<HabitFormModal
|
||||
isOpen={showEditModal}
|
||||
onDismiss={() => setShowEditModal(false)}
|
||||
onSubmit={updateHabit}
|
||||
initialValues={{
|
||||
name: habit.name,
|
||||
targetDescription: habit.target_description ?? '',
|
||||
color: habit.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<IonAlert
|
||||
isOpen={showDeleteAlert}
|
||||
onDidDismiss={() => setShowDeleteAlert(false)}
|
||||
header="Delete habit?"
|
||||
message={`Are you sure you want to delete "${habit?.name ?? ''}"? This will also remove all its completion history. This action cannot be undone.`}
|
||||
buttons={[
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
role: 'destructive',
|
||||
handler: deleteHabit,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default HabitDetail;
|
||||
@@ -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;
|
||||
@@ -1,30 +1,493 @@
|
||||
import React from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonRefresher,
|
||||
IonRefresherContent,
|
||||
IonSkeletonText,
|
||||
IonToolbar,
|
||||
} from "@ionic/react";
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
addOutline,
|
||||
analyticsOutline,
|
||||
checkmarkCircle,
|
||||
personCircleOutline,
|
||||
sparklesOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { PushNotifications } from '@capacitor/push-notifications';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Redirect, useHistory } from 'react-router-dom';
|
||||
import type { Tables } from '../database.types';
|
||||
import { supabase } from '../supabase';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import HabitCard from '../components/HabitCard';
|
||||
import HabitFormModal from '../components/HabitFormModal';
|
||||
|
||||
const Home: React.FC = () => (
|
||||
<IonPage>
|
||||
<IonHeader translucent className="home-header">
|
||||
<IonToolbar className="home-toolbar">
|
||||
<IonTitle>Home</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent fullscreen className="home-content">
|
||||
<div className="home-shell minimal-home-shell">
|
||||
<section className="minimal-welcome-card subtle-welcome-card">
|
||||
<h1>Welcome to your new app</h1>
|
||||
<p className="minimal-subtitle">
|
||||
Chat with the assistant to start adding features and pages
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
interface HabitWithMeta extends Tables<'habits'> {
|
||||
completions: Tables<'habit_completions'>[];
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||
|
||||
const calculateCurrentStreak = (dates: string[]) => {
|
||||
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
|
||||
let streak = 0;
|
||||
let cursor = new Date();
|
||||
|
||||
if (!unique.includes(formatDate(cursor))) {
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
|
||||
while (unique.includes(formatDate(cursor))) {
|
||||
streak += 1;
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
|
||||
return streak;
|
||||
};
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [busyHabitId, setBusyHabitId] = useState<string | null>(null);
|
||||
const pushTokenRef = useRef<string | null>(null);
|
||||
const pushListenersReadyRef = useRef(false);
|
||||
|
||||
const showError = (message: string) => {
|
||||
setError(message);
|
||||
window.setTimeout(() => setError(''), 4000);
|
||||
};
|
||||
|
||||
const registerPushNotifications = useCallback(
|
||||
async (currentUserId: string) => {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pushListenersReadyRef.current) {
|
||||
pushListenersReadyRef.current = true;
|
||||
|
||||
await PushNotifications.removeAllListeners();
|
||||
|
||||
PushNotifications.addListener('registration', async (token) => {
|
||||
if (pushTokenRef.current === token.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pushTokenRef.current = token.value;
|
||||
const { error: updateError } = await supabase
|
||||
.from('habits')
|
||||
.update({ fcm_token: token.value })
|
||||
.eq('user_id', currentUserId);
|
||||
|
||||
if (updateError) {
|
||||
showError(updateError.message);
|
||||
}
|
||||
});
|
||||
|
||||
PushNotifications.addListener(
|
||||
'registrationError',
|
||||
(registrationError) => {
|
||||
showError(registrationError.error ?? 'Push registration failed.');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const permissionStatus = await PushNotifications.checkPermissions();
|
||||
if (permissionStatus.receive === 'prompt') {
|
||||
const requested = await PushNotifications.requestPermissions();
|
||||
if (requested.receive !== 'granted') {
|
||||
return;
|
||||
}
|
||||
} else if (permissionStatus.receive !== 'granted') {
|
||||
return;
|
||||
}
|
||||
|
||||
await PushNotifications.register();
|
||||
},
|
||||
[showError]
|
||||
);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
setError('');
|
||||
|
||||
const {
|
||||
data: { session },
|
||||
error: sessionError,
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (sessionError) {
|
||||
setError(sessionError.message);
|
||||
setSessionChecked(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
setUserId(null);
|
||||
setSessionChecked(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setUserId(session.user.id);
|
||||
setSessionChecked(true);
|
||||
await registerPushNotifications(session.user.id);
|
||||
|
||||
const { data, error: habitsError } = await supabase
|
||||
.from('habits')
|
||||
.select('*, completions:habit_completions(*)')
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (habitsError) {
|
||||
setError(habitsError.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setHabits((data ?? []) as HabitWithMeta[]);
|
||||
setLoading(false);
|
||||
}, [registerPushNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
}, [loadDashboard]);
|
||||
|
||||
const today = formatDate(new Date());
|
||||
|
||||
const habitStats = useMemo(() => {
|
||||
const last7Days = Array.from({ length: 7 }, (_, index) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - index);
|
||||
return formatDate(date);
|
||||
});
|
||||
|
||||
return habits.reduce<
|
||||
Record<string, { streak: number; weekRate: number; doneToday: boolean }>
|
||||
>((acc, habit) => {
|
||||
const completionDates = habit.completions.map(
|
||||
(entry) => entry.completed_on
|
||||
);
|
||||
const completedInWeek = completionDates.filter((date) =>
|
||||
last7Days.includes(date)
|
||||
).length;
|
||||
|
||||
acc[habit.id] = {
|
||||
streak: calculateCurrentStreak(completionDates),
|
||||
weekRate: Math.round((completedInWeek / last7Days.length) * 100),
|
||||
doneToday: completionDates.includes(today),
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}, [habits, today]);
|
||||
|
||||
const totalCompletedToday = habits.filter(
|
||||
(habit) => habitStats[habit.id]?.doneToday
|
||||
).length;
|
||||
const longestStreak = habits.reduce(
|
||||
(max, habit) => Math.max(max, habitStats[habit.id]?.streak ?? 0),
|
||||
0
|
||||
);
|
||||
const completionRatio = habits.length
|
||||
? totalCompletedToday / habits.length
|
||||
: 0;
|
||||
const pendingCount = Math.max(habits.length - totalCompletedToday, 0);
|
||||
|
||||
const createHabit = async (values: {
|
||||
name: string;
|
||||
targetDescription: string;
|
||||
color: string;
|
||||
}) => {
|
||||
if (!userId) throw new Error('You need to be logged in.');
|
||||
|
||||
const { error: insertError } = await supabase.from('habits').insert({
|
||||
user_id: userId,
|
||||
name: values.name,
|
||||
target_description: values.targetDescription || null,
|
||||
color: values.color,
|
||||
});
|
||||
|
||||
if (insertError) throw insertError;
|
||||
await loadDashboard();
|
||||
};
|
||||
|
||||
const toggleHabit = async (habit: HabitWithMeta) => {
|
||||
if (!userId) return;
|
||||
setBusyHabitId(habit.id);
|
||||
|
||||
const existing = habit.completions.find(
|
||||
(entry) => entry.completed_on === today
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
const { error: deleteError } = await supabase
|
||||
.from('habit_completions')
|
||||
.delete()
|
||||
.eq('id', existing.id);
|
||||
if (deleteError) showError(deleteError.message);
|
||||
} else {
|
||||
const { error: insertError } = await supabase
|
||||
.from('habit_completions')
|
||||
.insert({
|
||||
habit_id: habit.id,
|
||||
user_id: userId,
|
||||
completed_on: today,
|
||||
});
|
||||
if (insertError) {
|
||||
showError(insertError.message);
|
||||
} else {
|
||||
const { error: pushError } = await supabase.functions.invoke(
|
||||
'send-habit-completion-push',
|
||||
{
|
||||
body: { habitId: habit.id },
|
||||
}
|
||||
);
|
||||
|
||||
if (pushError) {
|
||||
showError(pushError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await loadDashboard();
|
||||
setBusyHabitId(null);
|
||||
};
|
||||
|
||||
const deleteHabit = async (habitId: string) => {
|
||||
setBusyHabitId(habitId);
|
||||
const { error: deleteError } = await supabase
|
||||
.from('habits')
|
||||
.delete()
|
||||
.eq('id', habitId);
|
||||
if (deleteError) {
|
||||
showError(deleteError.message);
|
||||
}
|
||||
await loadDashboard();
|
||||
setBusyHabitId(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
void PushNotifications.removeAllListeners();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (sessionChecked && !userId) {
|
||||
return <Redirect to="/auth" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border home-header">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="end">
|
||||
<IonButton
|
||||
className="soft-icon-button"
|
||||
onClick={() => history.push('/stats')}
|
||||
>
|
||||
<IonIcon icon={analyticsOutline} />
|
||||
</IonButton>
|
||||
<IonButton
|
||||
className="soft-icon-button"
|
||||
onClick={() => history.push('/account')}
|
||||
>
|
||||
<IonIcon icon={personCircleOutline} />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent fullscreen className="home-content">
|
||||
<IonRefresher
|
||||
slot="fixed"
|
||||
onIonRefresh={async (e) => {
|
||||
await loadDashboard();
|
||||
e.detail.complete();
|
||||
}}
|
||||
>
|
||||
<IonRefresherContent />
|
||||
</IonRefresher>
|
||||
|
||||
<div className="dashboard-shell home-shell ion-padding">
|
||||
<section className="habits-hero">
|
||||
<div className="habits-hero-top">
|
||||
<div>
|
||||
<p className="hero-kicker">Habits ({habits.length})</p>
|
||||
<div className="hero-title-wrap">
|
||||
<h1>Daily</h1>
|
||||
<span>Weekly</span>
|
||||
</div>
|
||||
</div>
|
||||
<IonButton
|
||||
className="hero-add-button"
|
||||
fill="clear"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
<IonIcon icon={addOutline} />
|
||||
</IonButton>
|
||||
</div>
|
||||
|
||||
<div className="hero-progress-track" aria-hidden="true">
|
||||
<div
|
||||
className="hero-progress-fill"
|
||||
style={{ width: `${Math.max(completionRatio * 100, 6)}%` }}
|
||||
>
|
||||
<span className="hero-progress-dot" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero-progress-meta">
|
||||
<span>{totalCompletedToday} completed today</span>
|
||||
<span>{Math.round(completionRatio * 100)}% completed</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading ? (
|
||||
<div className="home-routine-panel">
|
||||
<div className="stack-list home-habit-stack">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
className="habit-card habit-card--editorial habit-card-skeleton"
|
||||
key={`habit-skeleton-${index}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-check"
|
||||
/>
|
||||
|
||||
<div className="habit-card-main">
|
||||
<div className="habit-copy">
|
||||
<div className="habit-copy-topline">
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-title"
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-color-pill"
|
||||
/>
|
||||
</div>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-description"
|
||||
/>
|
||||
<div className="habit-metrics-row">
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-metric"
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-metric short"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-action"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="inline-message error-card home-inline-card">
|
||||
<h3>Couldn't load your habits</h3>
|
||||
<p>{error}</p>
|
||||
<IonButton onClick={loadDashboard}>Try Again</IonButton>
|
||||
</div>
|
||||
) : habits.length === 0 ? (
|
||||
<div className="home-routine-panel">
|
||||
<EmptyState
|
||||
title="Start your first routine"
|
||||
message="Create one small daily habit and begin building your streak story today."
|
||||
actionLabel="Create First Habit"
|
||||
onAction={() => setShowCreateModal(true)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<section className="home-routine-panel">
|
||||
<div className="stack-list home-habit-stack">
|
||||
{habits.map((habit) => (
|
||||
<HabitCard
|
||||
key={habit.id}
|
||||
habit={habit}
|
||||
completedToday={habitStats[habit.id]?.doneToday ?? false}
|
||||
currentStreak={habitStats[habit.id]?.streak ?? 0}
|
||||
completionRate={habitStats[habit.id]?.weekRate ?? 0}
|
||||
onToggle={() => toggleHabit(habit)}
|
||||
onDelete={() => deleteHabit(habit.id)}
|
||||
busy={busyHabitId === habit.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="home-complete-section">
|
||||
<p className="home-section-label">Momentum</p>
|
||||
<div className="home-summary-card">
|
||||
<div
|
||||
className="summary-ring"
|
||||
style={{
|
||||
background: `conic-gradient(#7c3aed 0deg, #a855f7 ${completionRatio * 360}deg, rgba(109, 40, 217, 0.08) ${completionRatio * 360}deg 360deg)`,
|
||||
}}
|
||||
>
|
||||
<div className="summary-ring-inner">
|
||||
{Math.round(completionRatio * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-copy">
|
||||
<h3>Keep your rhythm</h3>
|
||||
<p>
|
||||
{pendingCount === 0
|
||||
? 'Everything is complete today. Enjoy the streak.'
|
||||
: `${pendingCount} habit${pendingCount === 1 ? '' : 's'} left to protect today's routine.`}
|
||||
</p>
|
||||
<div className="summary-metrics">
|
||||
<span>
|
||||
<IonIcon icon={sparklesOutline} /> {totalCompletedToday}{' '}
|
||||
done
|
||||
</span>
|
||||
<span>
|
||||
<IonIcon icon={checkmarkCircle} /> {longestStreak} day
|
||||
best streak
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<HabitFormModal
|
||||
isOpen={showCreateModal}
|
||||
onDismiss={() => setShowCreateModal(false)}
|
||||
onSubmit={createHabit}
|
||||
/>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
||||
@@ -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;
|
||||