Compare commits

..

1 Commits

Author SHA1 Message Date
AppCakes 0119c17e34 build: 91279040-7fca-48e0-9d50-5ce29aae63f6 2026-06-19 06:45:54 +00:00
121 changed files with 11018 additions and 8650 deletions
Vendored
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@ android {
namespace = "io.ionic.starter" namespace = "io.ionic.starter"
compileSdk = rootProject.ext.compileSdkVersion compileSdk = rootProject.ext.compileSdkVersion
defaultConfig { defaultConfig {
applicationId "com.habitmode.llemba.com" applicationId "com.inspot.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
+17 -1
View File
@@ -1,7 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -22,7 +26,19 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.inspot.app" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.inspot.app" />
</intent-filter>
</activity> </activity>
<meta-data <meta-data
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#FFFFFF</color> <color name="ic_launcher_background">#FFFFFF</color>
</resources> </resources>
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<resources> <resources>
<string name="app_name">app</string> <string name="app_name">InSpot</string>
<string name="title_activity_main">app</string> <string name="title_activity_main">InSpot</string>
<string name="package_name">io.ionic.starter</string> <string name="package_name">io.ionic.starter</string>
<string name="custom_url_scheme">io.ionic.starter</string> <string name="custom_url_scheme">io.ionic.starter</string>
</resources> </resources>
-1
View File
@@ -15,5 +15,4 @@ ext {
cordovaAndroidVersion = '14.0.1' cordovaAndroidVersion = '14.0.1'
rgcfaIncludeGoogle = true rgcfaIncludeGoogle = true
androidxCredentialsVersion = '1.3.0' androidxCredentialsVersion = '1.3.0'
javaVersion = JavaVersion.VERSION_17
} }
+3 -3
View File
@@ -1,13 +1,13 @@
import type { CapacitorConfig } from '@capacitor/cli'; import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = { const config: CapacitorConfig = {
appId: 'com.habitmode.llemba.com', appId: 'com.inspot.app',
appName: 'HabitMode', appName: 'InSpot',
webDir: 'dist', webDir: 'dist',
plugins: { plugins: {
FirebaseAuthentication: { FirebaseAuthentication: {
skipNativeAuth: false, skipNativeAuth: false,
providers: ["google.com", "apple.com"], providers: [],
}, },
}, },
}; };
-22
View File
@@ -55,13 +55,6 @@ workflows:
if [ -n "$CM_DEBUG_KEYSTORE" ]; then if [ -n "$CM_DEBUG_KEYSTORE" ]; then
echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12 echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12
fi 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 - name: Build debug APK
script: | script: |
cd android cd android
@@ -75,21 +68,6 @@ workflows:
else else
./gradlew assembleDebug --no-daemon ./gradlew assembleDebug --no-daemon
fi 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: artifacts:
- android/app/build/outputs/apk/debug/app-debug.apk - android/app/build/outputs/apk/debug/app-debug.apk
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -308,7 +308,7 @@
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com; PRODUCT_BUNDLE_IDENTIFIER = com.inspot.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -330,7 +330,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com; PRODUCT_BUNDLE_IDENTIFIER = com.inspot.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
+4 -10
View File
@@ -1,12 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>aps-environment</key>
<string>production</string>
</dict> </dict>
</plist> </plist>
+36 -18
View File
@@ -13,24 +13,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return true 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) { func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken Messaging.messaging().apnsToken = deviceToken
Messaging.messaging().token { token, error in Messaging.messaging().token { token, error in
@@ -45,4 +27,40 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: 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,14 +1,122 @@
{ {
"images" : [ "images": [
{ {
"filename" : "AppIcon-512@2x.png", "idiom": "iphone",
"idiom" : "universal", "scale": "1x",
"platform" : "ios", "size": "20x20",
"size" : "1024x1024" "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"
} }
], ],
"info" : { "info": {
"author" : "xcode", "author": "appsuite",
"version" : 1 "version": 1
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 979 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+23 -2
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>HabitMode</string> <string>InSpot</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -50,11 +50,32 @@
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
<key>CFBundleURLName</key>
<string>com.inspot.app</string>
<key>CFBundleURLSchemes</key> <key>CFBundleURLSchemes</key>
<array> <array>
<string>com.googleusercontent.apps.1097913965629-pf48952i0uv9shgvie3dq11s18sd0t2p</string> <string>com.inspot.app</string>
</array> </array>
</dict> </dict>
</array> </array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.inspot.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.inspot.app</string>
</array>
</dict>
</array>
<key>NSCameraUsageDescription</key>
<string>InSpot needs camera access to let you capture and share reels of local spots.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>InSpot needs photo library access to let you upload profile photos and share reels from your gallery.</string>
<key>NSMicrophoneUsageDescription</key>
<string>InSpot needs microphone access to record sound for your reels.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>InSpot needs your location to discover nearby spots and social connections.</string>
</dict> </dict>
</plist> </plist>
-3404
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -20,13 +20,15 @@
"@capacitor/keyboard": "8.0.3", "@capacitor/keyboard": "8.0.3",
"@capacitor/push-notifications": "^8.1.1", "@capacitor/push-notifications": "^8.1.1",
"@capacitor/status-bar": "8.0.2", "@capacitor/status-bar": "8.0.2",
"@ionic/pwa-elements": "^3.4.0", "@hookform/resolvers": "^3.9.0",
"@ionic/react": "^8.5.0", "@ionic/pwa-elements": "^3.0.0",
"@ionic/react-router": "^8.5.0", "@ionic/react": "8.8.7",
"@supabase/supabase-js": "^2.0.0", "@ionic/react-router": "8.8.7",
"@supabase/supabase-js": "^2.108.1",
"firebase": "^11.0.0", "firebase": "^11.0.0",
"ionicons": "^7.4.0", "ionicons": "^7.4.0",
"katex": "^0.16.0", "katex": "^0.16.0",
"lottie-react": "^2.4.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.54.0", "react-hook-form": "^7.54.0",
BIN
View File
Binary file not shown.
+130 -17
View File
@@ -1,12 +1,29 @@
import React from 'react'; import React from 'react';
import { Redirect, Route } from 'react-router-dom'; import { Redirect, Route } from 'react-router-dom';
import { IonApp, IonRouterOutlet } from '@ionic/react'; import {
IonApp,
IonIcon,
IonLabel,
IonRouterOutlet,
IonTabBar,
IonTabButton,
IonTabs,
} from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router'; import { IonReactRouter } from '@ionic/react-router';
import {
flameOutline,
locationOutline,
chatbubblesOutline,
personOutline,
} from 'ionicons/icons';
/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css'; import '@ionic/react/css/core.css';
import '@ionic/react/css/normalize.css'; import '@ionic/react/css/normalize.css';
import '@ionic/react/css/structure.css'; import '@ionic/react/css/structure.css';
import '@ionic/react/css/typography.css'; import '@ionic/react/css/typography.css';
/* Optional CSS utils that can be commented out */
import '@ionic/react/css/padding.css'; import '@ionic/react/css/padding.css';
import '@ionic/react/css/float-elements.css'; import '@ionic/react/css/float-elements.css';
import '@ionic/react/css/text-alignment.css'; import '@ionic/react/css/text-alignment.css';
@@ -14,26 +31,122 @@ import '@ionic/react/css/text-transformation.css';
import '@ionic/react/css/flex-utils.css'; import '@ionic/react/css/flex-utils.css';
import '@ionic/react/css/display.css'; import '@ionic/react/css/display.css';
/* Theme variables */
import './theme/variables.css'; import './theme/variables.css';
import Home from './pages/Home'; /* Pages */
import Account from './pages/Account'; import AuthPage from './pages/AuthPage';
import Auth from './pages/Auth'; import VerifyEmailPage from './pages/VerifyEmailPage';
import Stats from './pages/Stats'; import ForgotPasswordPage from './pages/ForgotPasswordPage';
import HabitDetail from './pages/HabitDetail'; 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';
const App = (): any => ( /* Contexts & Components */
import { AuthProvider } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import { isPreviewMode } from './utils/previewMode';
const App: React.FC = () => (
<IonApp> <IonApp>
<IonReactRouter> <AuthProvider>
<IonRouterOutlet> <IonReactRouter>
<Route exact path="/auth" component={Auth} /> <IonRouterOutlet>
<Route exact path="/home" component={Home} /> {/* Auth Stack */}
<Route exact path="/stats" component={Stats} /> <Route path="/auth" component={AuthPage} exact />
<Route exact path="/habit/:id" component={HabitDetail} /> <Route path="/verify-email" component={VerifyEmailPage} exact />
<Route exact path="/account" component={Account} /> <Route path="/forgot-password" component={ForgotPasswordPage} exact />
<Route exact path="/" render={() => <Redirect to="/home" />} /> <Route path="/new-password" component={NewPasswordPage} exact />
</IonRouterOutlet> <ProtectedRoute
</IonReactRouter> path="/onboarding/profile"
component={OnboardingProfilePage}
exact
/>
{/* Detailed Stack (No Tabs) */}
<ProtectedRoute path="/reels/:placeId" component={ReelsPage} exact />
<ProtectedRoute
path="/chat/:conversationId"
component={ChatThreadPage}
exact
/>
<ProtectedRoute
path="/profile/edit"
component={EditProfilePage}
exact
/>
<ProtectedRoute path="/premium" component={PremiumPage} exact />
<ProtectedRoute
path="/notifications"
component={NotificationsPage}
exact
/>
<ProtectedRoute path="/camera" component={CameraPage} exact />
{/* Main Tabs */}
<Route
path={['/icebreaker', '/hangout', '/chats', '/profile']}
render={() => (
<IonTabs>
<IonRouterOutlet>
<ProtectedRoute
path="/icebreaker"
component={IcebreakerPage}
exact
/>
<ProtectedRoute
path="/hangout"
component={HangoutPage}
exact
/>
<ProtectedRoute path="/chats" component={ChatsPage} exact />
<ProtectedRoute
path="/profile"
component={ProfilePage}
exact
/>
</IonRouterOutlet>
<IonTabBar slot="bottom">
<IonTabButton tab="icebreaker" href="/icebreaker">
<IonIcon icon={flameOutline} />
<IonLabel>Icebreaker</IonLabel>
</IonTabButton>
<IonTabButton tab="hangout" href="/hangout">
<IonIcon icon={locationOutline} />
<IonLabel>Hangout</IonLabel>
</IonTabButton>
<IonTabButton tab="chats" href="/chats">
<IonIcon icon={chatbubblesOutline} />
<IonLabel>Chats</IonLabel>
</IonTabButton>
<IonTabButton tab="profile" href="/profile">
<IonIcon icon={personOutline} />
<IonLabel>Profile</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
)}
/>
<Route
exact
path="/"
render={() => (
<Redirect to={isPreviewMode() ? '/icebreaker' : '/auth'} />
)}
/>
</IonRouterOutlet>
</IonReactRouter>
</AuthProvider>
</IonApp> </IonApp>
); );
Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

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

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

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

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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

After

Width:  |  Height:  |  Size: 148 KiB

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

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

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

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 752 KiB

+70
View File
@@ -0,0 +1,70 @@
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;
-77
View File
@@ -1,77 +0,0 @@
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;
+65 -13
View File
@@ -1,30 +1,82 @@
import React from 'react'; import React from 'react';
import { IonButton, IonIcon } from '@ionic/react'; import { IonButton, IonIcon } from '@ionic/react';
import { sparklesOutline } from 'ionicons/icons';
interface EmptyStateProps { interface EmptyStateProps {
icon: string;
title: string; title: string;
message: string; message: string;
actionLabel: string; ctaText?: string;
onAction: () => void; onCtaClick?: () => void;
style?: React.CSSProperties;
} }
const EmptyState: React.FC<EmptyStateProps> = ({ const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title, title,
message, message,
actionLabel, ctaText,
onAction, onCtaClick,
style = {},
}) => { }) => {
return ( return (
<div className="empty-state-card home-empty-state-card"> <div
<div className="empty-state-icon"> style={{
<IonIcon icon={sparklesOutline} /> 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> </div>
<h2>{title}</h2> <h3
<p>{message}</p> style={{
<IonButton className="home-empty-state-button" onClick={onAction}> fontSize: '20px',
{actionLabel} fontWeight: 700,
</IonButton> margin: '0 0 8px',
color: 'var(--color-text-primary)',
}}
>
{title}
</h3>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: '0 0 24px',
lineHeight: 1.5,
}}
>
{message}
</p>
{ctaText && onCtaClick && (
<IonButton
expand="block"
onClick={onCtaClick}
style={{ '--border-radius': 'var(--radius-pill)', minWidth: '200px' }}
>
{ctaText}
</IonButton>
)}
</div> </div>
); );
}; };
-108
View File
@@ -1,108 +0,0 @@
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;
-153
View File
@@ -1,153 +0,0 @@
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;
+97
View File
@@ -0,0 +1,97 @@
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;
-69
View File
@@ -1,69 +0,0 @@
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;
+27
View File
@@ -0,0 +1,27 @@
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;
-80
View File
@@ -1,80 +0,0 @@
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;
+68
View File
@@ -0,0 +1,68 @@
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;
-47
View File
@@ -1,47 +0,0 @@
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;
+107
View File
@@ -0,0 +1,107 @@
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;
};
+549 -64
View File
@@ -14,125 +14,610 @@ export type Database = {
} }
public: { public: {
Tables: { Tables: {
habit_completions: { check_ins: {
Row: { Row: {
completed_on: string checked_in_at: string | null
created_at: string expires_at: string
habit_id: string
id: string id: string
place_id: string
user_id: string user_id: string
} }
Insert: { Insert: {
completed_on: string checked_in_at?: string | null
created_at?: string expires_at: string
habit_id: string
id?: string id?: string
place_id: string
user_id: string user_id: string
} }
Update: { Update: {
completed_on?: string checked_in_at?: string | null
created_at?: string expires_at?: string
habit_id?: string
id?: string id?: string
place_id?: string
user_id?: string user_id?: string
} }
Relationships: [ Relationships: [
{ {
foreignKeyName: "habit_completions_habit_id_fkey" foreignKeyName: "check_ins_place_id_fkey"
columns: ["habit_id"] columns: ["place_id"]
isOneToOne: false isOneToOne: false
referencedRelation: "habits" referencedRelation: "places"
referencedColumns: ["id"]
},
{
foreignKeyName: "check_ins_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
] ]
} }
habits: { conversations: {
Row: { Row: {
color: string created_at: string | null
created_at: string
fcm_token: string | null
id: string id: string
name: string match_id: string
target_description: string | null updated_at: string | null
user_id: string
} }
Insert: { Insert: {
color?: string created_at?: string | null
created_at?: string
fcm_token?: string | null
id?: string id?: string
name: string match_id: string
target_description?: string | null updated_at?: string | null
user_id: string
} }
Update: { Update: {
color?: string created_at?: string | null
created_at?: string
fcm_token?: string | null
id?: string id?: string
name?: string match_id?: string
target_description?: string | null updated_at?: string | null
user_id?: string
} }
Relationships: [] Relationships: [
{
foreignKeyName: "conversations_match_id_fkey"
columns: ["match_id"]
isOneToOne: true
referencedRelation: "matches"
referencedColumns: ["id"]
},
]
} }
notes: { discovery_preferences: {
Row: { Row: {
content: string goals: string[]
created_at: string max_age: number
id: string max_distance_km: number
is_favorite: boolean min_age: number
title: string updated_at: string | null
user_id: string user_id: string
} }
Insert: { Insert: {
content: string goals?: string[]
created_at?: string max_age?: number
id?: string max_distance_km?: number
is_favorite?: boolean min_age?: number
title: string updated_at?: string | null
user_id?: string user_id: string
} }
Update: { Update: {
content?: string goals?: string[]
created_at?: 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 id?: string
is_favorite?: boolean 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 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 user_id?: string
} }
Relationships: [] Relationships: []
} }
todos: { places: {
Row: { Row: {
created_at: string address: string | null
busyness: string
category: string
created_at: string | null
created_by: string | null
description: string | null description: string | null
due_date: string | null google_place_id: string | null
id: string id: string
is_completed: boolean image_path: string | null
title: string 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 user_id: string
} }
Insert: { Insert: {
created_at?: string created_at?: string | null
description?: string | null
due_date?: string | null
id?: string id?: string
is_completed?: boolean is_primary?: boolean
title: string position?: number
user_id?: string storage_path: string
user_id: string
} }
Update: { Update: {
created_at?: string created_at?: string | null
description?: string | null
due_date?: string | null
id?: string id?: string
is_completed?: boolean is_primary?: boolean
title?: string position?: number
storage_path?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "profile_photos_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
profiles: {
Row: {
avatar_path: string | null
bio: string | null
birth_date: string | null
city: string | null
created_at: string | null
fcm_token: string | null
full_name: string
gender: string | null
id: string
interests: string[]
latitude: number | null
longitude: number | null
onboarding_complete: boolean
relationship_goal: string | null
updated_at: string | null
}
Insert: {
avatar_path?: string | null
bio?: string | null
birth_date?: string | null
city?: string | null
created_at?: string | null
fcm_token?: string | null
full_name?: string
gender?: string | null
id: string
interests?: string[]
latitude?: number | null
longitude?: number | null
onboarding_complete?: boolean
relationship_goal?: string | null
updated_at?: string | null
}
Update: {
avatar_path?: string | null
bio?: string | null
birth_date?: string | null
city?: string | null
created_at?: string | null
fcm_token?: string | null
full_name?: string
gender?: string | null
id?: string
interests?: string[]
latitude?: number | null
longitude?: number | null
onboarding_complete?: boolean
relationship_goal?: string | null
updated_at?: string | null
}
Relationships: []
}
reel_comments: {
Row: {
body: string
created_at: string | null
id: string
reel_id: string
user_id: string
}
Insert: {
body: string
created_at?: string | null
id?: string
reel_id: string
user_id: string
}
Update: {
body?: string
created_at?: string | null
id?: string
reel_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "reel_comments_reel_id_fkey"
columns: ["reel_id"]
isOneToOne: false
referencedRelation: "reels"
referencedColumns: ["id"]
},
{
foreignKeyName: "reel_comments_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
reel_likes: {
Row: {
created_at: string | null
reel_id: string
user_id: string
}
Insert: {
created_at?: string | null
reel_id: string
user_id: string
}
Update: {
created_at?: string | null
reel_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "reel_likes_reel_id_fkey"
columns: ["reel_id"]
isOneToOne: false
referencedRelation: "reels"
referencedColumns: ["id"]
},
{
foreignKeyName: "reel_likes_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
reels: {
Row: {
author_id: string
caption: string
comments_count: number
created_at: string | null
id: string
likes_count: number
media_path: string
media_type: string
place_id: string
shares_count: number
thumbnail_path: string | null
}
Insert: {
author_id: string
caption?: string
comments_count?: number
created_at?: string | null
id?: string
likes_count?: number
media_path: string
media_type: string
place_id: string
shares_count?: number
thumbnail_path?: string | null
}
Update: {
author_id?: string
caption?: string
comments_count?: number
created_at?: string | null
id?: string
likes_count?: number
media_path?: string
media_type?: string
place_id?: string
shares_count?: number
thumbnail_path?: string | null
}
Relationships: [
{
foreignKeyName: "reels_author_id_fkey"
columns: ["author_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "reels_place_id_fkey"
columns: ["place_id"]
isOneToOne: false
referencedRelation: "places"
referencedColumns: ["id"]
},
]
}
subscriptions: {
Row: {
active_until: string | null
tier: string
updated_at: string | null
user_id: string
}
Insert: {
active_until?: string | null
tier?: string
updated_at?: string | null
user_id: string
}
Update: {
active_until?: string | null
tier?: string
updated_at?: string | null
user_id?: string user_id?: string
} }
Relationships: [] Relationships: []
} }
swipes: {
Row: {
action: string
created_at: string | null
id: string
swiped_id: string
swiper_id: string
}
Insert: {
action: string
created_at?: string | null
id?: string
swiped_id: string
swiper_id: string
}
Update: {
action?: string
created_at?: string | null
id?: string
swiped_id?: string
swiper_id?: string
}
Relationships: [
{
foreignKeyName: "swipes_swiped_id_fkey"
columns: ["swiped_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "swipes_swiper_id_fkey"
columns: ["swiper_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
} }
Views: { Views: {
[_ in never]: never [_ in never]: never
+10 -5
View File
@@ -5,7 +5,7 @@ import { Capacitor } from '@capacitor/core';
import App from './App'; import App from './App';
// ── Ionic initialisation ────────────────────────────────────────────────────── // ── Ionic initialisation ──────────────────────────────────────────────────────
// ?mode=md|ios overrides (used by AppSuite preview); otherwise follow the platform. // ?mode=md|ios overrides (used by Appcakes preview); otherwise follow the platform.
const _urlMode = new URLSearchParams(window.location.search).get('mode'); const _urlMode = new URLSearchParams(window.location.search).get('mode');
const _platform = Capacitor.getPlatform(); // 'ios' | 'android' | 'web' const _platform = Capacitor.getPlatform(); // 'ios' | 'android' | 'web'
setupIonicReact({ setupIonicReact({
@@ -13,7 +13,7 @@ setupIonicReact({
}); });
// Derive the studio parent origin dynamically so this works in both dev // Derive the studio parent origin dynamically so this works in both dev
// (parent at localhost:3000) and production (parent at appcakes.qqura.com). // (parent at localhost:3000) and production (parent at studio.appcakes.dev).
const studioOrigin = (() => { const studioOrigin = (() => {
try { try {
if (document.referrer) return new URL(document.referrer).origin; if (document.referrer) return new URL(document.referrer).origin;
@@ -49,7 +49,7 @@ if (import.meta.hot) {
} }
// ── Session bridge ──────────────────────────────────────────────────────────── // ── Session bridge ────────────────────────────────────────────────────────────
// Post auth session to the AppSuite parent frame so the AI can test auth-protected // Post auth session to the Appcakes parent frame so the AI can test auth-protected
// Edge Functions. Uses import.meta.glob so Vite never errors if supabase.ts is absent. // Edge Functions. Uses import.meta.glob so Vite never errors if supabase.ts is absent.
(async () => { (async () => {
const mods = import.meta.glob('./supabase.ts', { eager: false }); const mods = import.meta.glob('./supabase.ts', { eager: false });
@@ -73,12 +73,17 @@ if (import.meta.hot) {
})(); })();
// ── Runtime error reporting ─────────────────────────────────────────────────── // ── Runtime error reporting ───────────────────────────────────────────────────
// Forward uncaught errors to the AppSuite dev server so the AI can read them. // 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;
function reportError(message: string, stack?: string) { function reportError(message: string, stack?: string) {
fetch(`${studioOrigin}/api/agent/runtime-error`, { fetch(`${studioOrigin}/api/agent/runtime-error`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, stack }), body: JSON.stringify({ message, stack, projectId: _projectId }),
}).catch(() => {}); }).catch(() => {});
} }
-196
View File
@@ -1,196 +0,0 @@
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;
-398
View File
@@ -1,398 +0,0 @@
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;
+425
View File
@@ -0,0 +1,425 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonInput,
IonItem,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonRouterLink,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { mail, lockClosed, logoFacebook } from 'ionicons/icons';
import { IonIcon } from '@ionic/react';
import { enablePreviewMode } from '../utils/previewMode';
import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { Capacitor } from '@capacitor/core';
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
const AuthPage: React.FC = () => {
const history = useHistory();
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleAuth = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
if (mode === 'signin') {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
if (error.message.toLowerCase().includes('email not confirmed')) {
await supabase.auth.resend({ type: 'signup', email });
history.push('/verify-email', { email, type: 'signup' });
return;
}
throw error;
}
history.push('/icebreaker');
} else {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: { onboarding_complete: false },
},
});
if (error) throw error;
if (data.session) {
history.push('/onboarding/profile');
} else {
history.push('/verify-email', { email, type: 'signup' });
}
}
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
const enterPreviewMode = () => {
enablePreviewMode();
history.push('/icebreaker');
};
const handleSocialSignIn = async (provider: 'google' | 'apple') => {
if (!Capacitor.isNativePlatform()) {
showError(
`${provider === 'google' ? 'Google' : 'Apple'} Sign-In is only available on native devices.`
);
return;
}
setLoading(true);
try {
let result;
if (provider === 'google') {
result = await FirebaseAuthentication.signInWithGoogle();
} else {
result = await FirebaseAuthentication.signInWithApple({
skipNativeAuth: true,
});
}
const { error } = await supabase.auth.signInWithIdToken({
provider,
token: result.credential!.idToken!,
nonce: result.credential?.nonce,
});
if (error) throw error;
history.push('/icebreaker');
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
className="ion-padding"
style={{
'--background': 'var(--color-bg)',
display: 'flex',
flexDirection: 'column',
paddingTop: 'calc(var(--ion-safe-area-top) + 40px)',
}}
>
<div style={{ padding: '0 20px' }}>
<div style={{ marginBottom: '44px', paddingTop: '24px' }}>
<h1
style={{
fontSize: '28px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 42px',
}}
>
{mode === 'signin' ? 'Hi there, 👋' : 'Create account'}
</h1>
<h2
style={{
fontSize: '24px',
fontWeight: 700,
color: 'var(--color-text-primary)',
margin: '0 0 12px',
}}
>
{mode === 'signin'
? 'Sign in to continue'
: 'Sign up to continue'}
</h2>
<p
style={{
fontSize: '16px',
color: 'var(--color-text-secondary)',
margin: 0,
lineHeight: 1.35,
}}
>
{mode === 'signin'
? "Don't have an account? Enter your email and password or choose one of the options below."
: 'Enter your email and password to start discovering people and places nearby.'}
</p>
</div>
<div
style={{
display: 'flex',
background: 'var(--color-surface-raised)',
padding: '4px',
borderRadius: 'var(--radius-pill)',
marginBottom: '20px',
}}
>
{(['signin', 'signup'] as const).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
style={{
flex: 1,
padding: '10px',
borderRadius: 'var(--radius-pill)',
border: 'none',
fontSize: '14px',
fontWeight: 700,
transition: '0.2s',
background:
mode === m ? 'var(--ion-color-primary)' : 'transparent',
color: mode === m ? '#ffffff' : 'var(--color-text-primary)',
}}
>
{m === 'signin' ? 'Sign In' : 'Sign Up'}
</button>
))}
</div>
<form
onSubmit={handleAuth}
style={{
background: 'transparent',
padding: '0',
borderRadius: '0',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-input)',
'--border-radius': 'var(--radius-control)',
'--border-width': '1px',
'--border-style': 'solid',
'--border-color': 'var(--color-text-secondary)',
'--min-height': '48px',
}}
>
<IonIcon
icon={mail}
slot="start"
style={{ color: 'var(--ion-color-primary)', fontSize: '20px' }}
/>
<IonInput
label="Email"
labelPlacement="floating"
type="email"
value={email}
onIonInput={(e) => setEmail(e.detail.value!)}
required
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-input)',
'--border-radius': 'var(--radius-control)',
'--border-width': '1px',
'--border-style': 'solid',
'--border-color': 'var(--color-text-secondary)',
'--min-height': '48px',
}}
>
<IonIcon
icon={lockClosed}
slot="start"
style={{ color: 'var(--ion-color-primary)', fontSize: '20px' }}
/>
<IonInput
label="Password"
labelPlacement="floating"
type="password"
value={password}
onIonInput={(e) => setPassword(e.detail.value!)}
required
/>
</IonItem>
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
}}
>
{error}
</div>
)}
<IonButton
type="submit"
expand="block"
disabled={loading}
style={{
'--border-radius': 'var(--radius-pill)',
'--color': '#000000',
fontWeight: 800,
height: '48px',
margin: '8px 28px 0',
}}
>
{loading ? (
<IonSpinner name="crescent" />
) : mode === 'signin' ? (
'Sign in'
) : (
'Continue'
)}
</IonButton>
{mode === 'signin' && (
<IonRouterLink
routerLink="/forgot-password"
style={{
textAlign: 'center',
fontSize: '13px',
color: 'var(--ion-color-primary)',
marginTop: '8px',
}}
>
Forgot password?
</IonRouterLink>
)}
</form>
<div
style={{
textAlign: 'center',
margin: '56px 0 26px',
fontSize: '13px',
color: 'var(--color-text-tertiary)',
}}
>
OR
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
padding: '0 28px',
}}
>
<IonButton
expand="block"
onClick={() => handleSocialSignIn('apple')}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
style={{ marginRight: '10px' }}
>
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
Sign in with Apple
</IonButton>
<IonButton
expand="block"
onClick={() =>
showError(
'Facebook Sign-In is only available on native devices.'
)
}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<IonIcon
icon={logoFacebook}
style={{ marginRight: '10px', fontSize: '22px' }}
/>
Sign in with Facebook
</IonButton>
<IonButton
expand="block"
onClick={() => handleSocialSignIn('google')}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<span
style={{
fontSize: '24px',
fontWeight: 900,
marginRight: '12px',
}}
>
G
</span>
Sign in with Google
</IonButton>
<IonButton
expand="block"
onClick={enterPreviewMode}
style={{
flex: 1,
'--background': 'var(--ion-color-primary)',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
Preview with dummy data
</IonButton>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default AuthPage;
+311
View File
@@ -0,0 +1,311 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonIcon,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonItem,
IonTextarea,
IonSelect,
IonSelectOption,
} from '@ionic/react';
import {
closeOutline,
cameraOutline,
imageOutline,
sendOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const CameraPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [caption, setCaption] = useState('');
const [placeId, setPlaceId] = useState<string | null>(null);
const [places, setPlaces] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [isPublishOpen, setIsPublishOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchPlaces();
});
const fetchPlaces = async () => {
const { data } = await supabase.from('places').select('id, name');
setPlaces(data || []);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewUrl(URL.createObjectURL(selectedFile));
setIsPublishOpen(true);
}
};
const handlePublish = async () => {
if (!file || !user || !placeId) {
setError('Please select a place and add a file.');
return;
}
setUploading(true);
try {
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
const filePath = `${fileName}`;
// 1. Upload to storage
const { error: uploadError } = await supabase.storage
.from('reels')
.upload(filePath, file);
if (uploadError) throw uploadError;
// 2. Create reel record
const { error: reelError } = await supabase.from('reels').insert({
author_id: user.id,
place_id: placeId,
caption,
media_path: filePath,
media_type: file.type.startsWith('video') ? 'video' : 'image',
});
if (reelError) throw reelError;
history.replace(`/reels/${placeId}`);
} catch (err: any) {
setError(err.message);
} finally {
setUploading(false);
}
};
return (
<IonPage>
<IonContent fullscreen style={{ '--background': '#000000' }}>
<div
style={{
position: 'absolute',
top: 'calc(14px + var(--ion-safe-area-top))',
left: '20px',
zIndex: 10,
}}
>
<IonButton
fill="clear"
onClick={() => history.goBack()}
style={{
'--background': 'rgba(0,0,0,0.3)',
'--color': '#ffffff',
'--border-radius': '50%',
width: '40px',
height: '40px',
margin: 0,
}}
>
<IonIcon icon={closeOutline} slot="icon-only" />
</IonButton>
</div>
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
}}
>
<div
style={{
width: '120px',
height: '120px',
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '24px',
}}
>
<IonIcon
icon={cameraOutline}
style={{ fontSize: '48px', opacity: 0.5 }}
/>
</div>
<h2
style={{ fontSize: '20px', fontWeight: 800, marginBottom: '8px' }}
>
Create a Reel
</h2>
<p
style={{
opacity: 0.6,
fontSize: '14px',
marginBottom: '32px',
textAlign: 'center',
padding: '0 40px',
}}
>
Share your experience at a local spot. Select a photo or video to
begin.
</p>
<label
style={{
background: 'var(--ion-color-primary)',
color: '#ffffff',
padding: '14px 32px',
borderRadius: 'var(--radius-pill)',
fontWeight: 700,
fontSize: '15px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<IonIcon icon={imageOutline} style={{ fontSize: '20px' }} />
Select Media
<input
type="file"
accept="image/*,video/*"
onChange={handleFileChange}
hidden
/>
</label>
</div>
{/* Publish Modal */}
<IonModal
isOpen={isPublishOpen}
onDidDismiss={() => {
setIsPublishOpen(false);
setPreviewUrl(null);
setFile(null);
}}
>
<IonHeader>
<IonToolbar>
<IonTitle>New Reel</IonTitle>
<IonButtons slot="start">
<IonButton onClick={() => setIsPublishOpen(false)}>
Cancel
</IonButton>
</IonButtons>
<IonButtons slot="end">
<IonButton
onClick={handlePublish}
disabled={uploading || !placeId}
>
{uploading ? <IonSpinner name="crescent" /> : 'Share'}
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
<div
style={{
width: '100px',
height: '140px',
borderRadius: '12px',
overflow: 'hidden',
background: '#000',
flexShrink: 0,
}}
>
{file?.type.startsWith('video') ? (
<video
src={previewUrl || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<img
src={previewUrl || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
alt="Preview"
/>
)}
</div>
<IonTextarea
placeholder="Write a caption..."
value={caption}
onIonInput={(e) => setCaption(e.detail.value!)}
rows={6}
style={{
'--background': 'var(--color-bg)',
'--padding-start': '12px',
'--border-radius': '12px',
}}
/>
</div>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Tag a place"
labelPlacement="floating"
placeholder="Select place"
value={placeId}
onIonChange={(e) => setPlaceId(e.detail.value)}
>
{places.map((p) => (
<IonSelectOption key={p.id} value={p.id}>
{p.name}
</IonSelectOption>
))}
</IonSelect>
</IonItem>
{error && (
<div
style={{
color: 'var(--ion-color-danger)',
fontSize: '13px',
marginTop: '12px',
padding: '0 4px',
}}
>
{error}
</div>
)}
</IonContent>
</IonModal>
</IonContent>
</IonPage>
);
};
export default CameraPage;
+263
View File
@@ -0,0 +1,263 @@
import React, { useState, useEffect, useRef } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonInput,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { chevronBackOutline, sendOutline } from 'ionicons/icons';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import Avatar from '../components/Avatar';
import { previewChats, previewMessages } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const ChatThreadPage: React.FC = () => {
const history = useHistory();
const { conversationId } = useParams<{ conversationId: string }>();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [messages, setMessages] = useState<any[]>([]);
const [otherUser, setOtherUser] = useState<any>(null);
const [text, setText] = useState('');
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLIonContentElement>(null);
const fetchThread = async () => {
setLoading(true);
if (previewMode) {
const chat =
previewChats.find((item) => item.id === conversationId) ||
previewChats[0];
setOtherUser(chat.otherUser);
setMessages(previewMessages[chat.id] || []);
setLoading(false);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
return;
}
if (!user) return;
try {
// 1. Get other participant
const { data: conv } = await supabase
.from('conversations')
.select(
`
match_id,
matches(
user_a:profiles!matches_user_a_fkey(id, full_name, avatar_path),
user_b:profiles!matches_user_b_fkey(id, full_name, avatar_path)
)
`
)
.eq('id', conversationId)
.single();
if (conv) {
const match = (conv as any).matches;
setOtherUser(match.user_a.id === user.id ? match.user_b : match.user_a);
}
// 2. Get messages
const { data: msgs } = await supabase
.from('messages')
.select('*')
.eq('conversation_id', conversationId)
.order('created_at', { ascending: true });
setMessages(msgs || []);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
} catch (err: any) {
console.error('Fetch thread error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchThread();
});
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim() || (!user && !previewMode) || sending) return;
setSending(true);
const msgBody = text.trim();
setText('');
if (previewMode) {
const previewMessage = {
id: `preview-message-${Date.now()}`,
sender_id: 'preview-me',
body: msgBody,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, previewMessage]);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
setSending(false);
return;
}
try {
const { data, error } = await supabase
.from('messages')
.insert({
conversation_id: conversationId,
sender_id: user.id,
body: msgBody,
})
.select()
.single();
if (error) throw error;
setMessages((prev) => [...prev, data]);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
// Invoke notification
supabase.functions.invoke('send-message-notification', {
body: { conversationId, messageId: data.id },
});
} catch (err: any) {
console.error('Send error:', err);
} finally {
setSending(false);
}
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
<IonButtons slot="start">
<IonButton
onClick={() => history.goBack()}
style={{ '--color': 'var(--color-text-primary)' }}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle>
{otherUser && (
<div
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
<Avatar
src={otherUser.avatar_path}
name={otherUser.full_name}
size={32}
/>
<span style={{ fontSize: '16px', fontWeight: 700 }}>
{otherUser.full_name}
</span>
</div>
)}
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent
ref={scrollRef}
style={{ '--background': 'var(--color-bg)' }}
className="ion-padding"
>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
paddingBottom: '20px',
}}
>
{messages.map((msg) => {
const isMine = previewMode
? msg.sender_id === 'preview-me'
: msg.sender_id === user?.id;
return (
<div
key={msg.id}
style={{
alignSelf: isMine ? 'flex-end' : 'flex-start',
maxWidth: '78%',
background: isMine
? 'var(--ion-color-primary)'
: 'var(--color-surface)',
color: isMine ? '#ffffff' : 'var(--color-text-primary)',
padding: '10px 14px',
borderRadius: isMine
? '18px 18px 4px 18px'
: '18px 18px 18px 4px',
fontSize: '14px',
lineHeight: 1.4,
boxShadow: '0 2px 8px rgba(76, 5, 25, 0.02)',
}}
>
{msg.body}
</div>
);
})}
</div>
)}
</IonContent>
<div
style={{
background: '#ffffff',
padding: '10px 20px calc(10px + var(--ion-safe-area-bottom))',
borderTop: '1px solid var(--color-border)',
}}
>
<form
onSubmit={handleSend}
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
>
<IonInput
value={text}
onIonInput={(e) => setText(e.detail.value!)}
placeholder="Type a message..."
style={{
background: 'var(--color-bg)',
'--border-radius': 'var(--radius-pill)',
'--padding-start': '16px',
fontSize: '14px',
}}
/>
<IonButton
type="submit"
disabled={!text.trim() || sending}
style={{
'--border-radius': '50%',
width: '44px',
height: '44px',
margin: 0,
flexShrink: 0,
}}
>
{sending ? (
<IonSpinner name="crescent" />
) : (
<IonIcon icon={sendOutline} slot="icon-only" />
)}
</IonButton>
</form>
</div>
</IonPage>
);
};
export default ChatThreadPage;
+218
View File
@@ -0,0 +1,218 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonSpinner,
useIonViewWillEnter,
IonRefresher,
IonRefresherContent,
IonIcon,
} from '@ionic/react';
import { chatbubbleEllipsesOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { formatTime } from '../utils/format';
import SearchRow from '../components/SearchRow';
import Avatar from '../components/Avatar';
import EmptyState from '../components/EmptyState';
import { previewChats } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const ChatsPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [chats, setChats] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const fetchChats = async () => {
if (previewMode) {
setChats(previewChats);
setLoading(false);
return;
}
if (!user) return;
setLoading(true);
try {
const { data, error } = await supabase
.from('matches')
.select(
`
id,
last_message_at,
user_a:profiles!matches_user_a_fkey(id, full_name, avatar_path),
user_b:profiles!matches_user_b_fkey(id, full_name, avatar_path),
conversations(
id,
messages(
body,
created_at
)
)
`
)
.or(`user_a.eq.${user.id},user_b.eq.${user.id}`)
.order('last_message_at', { ascending: false });
if (error) throw error;
const formatted =
data?.map((m) => {
const otherUser = m.user_a.id === user.id ? m.user_b : m.user_a;
const conversation = m.conversations?.[0];
const lastMsg = conversation?.messages?.[0]; // ordered by handle-swipe or trigger later
return {
id: conversation?.id,
otherUser,
lastMessage: lastMsg?.body || 'New match! Start chatting.',
timestamp: m.last_message_at || lastMsg?.created_at,
};
}) || [];
setChats(formatted);
} catch (err: any) {
console.error('Fetch chats error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchChats();
});
const handleRefresh = async (event: CustomEvent) => {
await fetchChats();
event.detail.complete();
};
const displayChats = previewMode ? previewChats : chats;
const filteredChats = displayChats.filter(
(c) =>
c.otherUser.full_name.toLowerCase().includes(query.toLowerCase()) ||
c.lastMessage.toLowerCase().includes(query.toLowerCase())
);
return (
<IonPage>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div
style={{ padding: 'calc(16px + var(--ion-safe-area-top)) 20px 12px' }}
>
<h1
style={{
fontSize: '24px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: 0,
}}
>
Chats
</h1>
</div>
<SearchRow
value={query}
onChange={(e) => setQuery(e.detail.value!)}
placeholder="Search conversations"
/>
<div
style={{ paddingBottom: 'calc(92px + var(--ion-safe-area-bottom))' }}
>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : filteredChats.length === 0 ? (
<EmptyState
icon={chatbubbleEllipsesOutline}
title="No chats yet"
message="Matches you message will appear here. Start swiping to find someone!"
ctaText="Find people"
onCtaClick={() => history.push('/icebreaker')}
/>
) : (
filteredChats.map((chat) => (
<div
key={chat.id}
onClick={() => history.push(`/chat/${chat.id}`)}
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
margin: '0 20px 12px',
padding: '16px',
display: 'flex',
alignItems: 'center',
gap: '12px',
boxShadow: 'var(--shadow-card)',
}}
>
<Avatar
src={chat.otherUser.image || chat.otherUser.avatar_path}
name={chat.otherUser.full_name}
size={56}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '4px',
}}
>
<h3
style={{
fontSize: '16px',
fontWeight: 800,
margin: 0,
color: 'var(--color-text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{chat.otherUser.full_name}
</h3>
<span
style={{
fontSize: '11px',
color: 'var(--color-text-tertiary)',
fontWeight: 600,
}}
>
{formatTime(chat.timestamp)}
</span>
</div>
<p
style={{
fontSize: '14px',
color: 'var(--color-text-secondary)',
margin: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{chat.lastMessage}
</p>
</div>
</div>
))
)}
</div>
</IonContent>
</IonPage>
);
};
export default ChatsPage;
+378
View File
@@ -0,0 +1,378 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonItem,
IonInput,
IonTextarea,
IonSelect,
IonSelectOption,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import {
chevronBackOutline,
cameraOutline,
trashOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { getStorageUrl } from '../utils/storage';
const EditProfilePage: React.FC = () => {
const history = useHistory();
const { user, profile, refreshProfile } = useAuth();
const [fullName, setFullName] = useState(profile?.full_name || '');
const [bio, setBio] = useState(profile?.bio || '');
const [gender, setGender] = useState(profile?.gender || '');
const [relationshipGoal, setRelationshipGoal] = useState(
profile?.relationship_goal || ''
);
const [loading, setLoading] = useState(false);
const [photos, setPhotos] = useState<any[]>([]);
const [uploading, setUploading] = useState(false);
const fetchPhotos = async () => {
if (!user) return;
const { data } = await supabase
.from('profile_photos')
.select('*')
.eq('user_id', user.id)
.order('position');
setPhotos(data || []);
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
if (profile) {
setFullName(profile.full_name);
setBio(profile.bio || '');
setGender(profile.gender || '');
setRelationshipGoal(profile.relationship_goal || '');
}
fetchPhotos();
});
const handleSave = async () => {
if (!user) return;
setLoading(true);
try {
const { error } = await supabase
.from('profiles')
.update({
full_name: fullName,
bio,
gender,
relationship_goal: relationshipGoal,
})
.eq('id', user.id);
if (error) throw error;
await refreshProfile();
history.goBack();
} catch (err: any) {
console.error('Save error:', err);
} finally {
setLoading(false);
}
};
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !user) return;
setUploading(true);
try {
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
const filePath = `profile-photos/${fileName}`;
const { error: uploadError } = await supabase.storage
.from('profile-photos')
.upload(filePath, file);
if (uploadError) throw uploadError;
const { error: dbError } = await supabase.from('profile_photos').insert({
user_id: user.id,
storage_path: filePath,
position: photos.length,
});
if (dbError) throw dbError;
fetchPhotos();
} catch (err: any) {
console.error('Photo upload error:', err);
} finally {
setUploading(false);
}
};
const deletePhoto = async (id: string, path: string) => {
try {
await supabase.from('profile_photos').delete().eq('id', id);
await supabase.storage.from('profile-photos').remove([path]);
fetchPhotos();
} catch (err) {
console.error('Delete photo error:', err);
}
};
const goals = [
{ label: 'Long-term partner', value: 'long_term' },
{ label: 'Long-term, but short-term OK', value: 'long_term_short_ok' },
{ label: 'Short-term, but long-term OK', value: 'short_term_long_ok' },
{ label: 'Short-term fun', value: 'short_term' },
{ label: 'New friends', value: 'friends' },
{ label: 'Still figuring it out', value: 'figuring_it_out' },
];
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
<IonButtons slot="start">
<IonButton
onClick={() => history.goBack()}
style={{ '--color': 'var(--color-text-primary)' }}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle>Edit Profile</IonTitle>
<IonButtons slot="end">
<IonButton
onClick={handleSave}
disabled={loading}
style={{ fontWeight: 700 }}
>
{loading ? <IonSpinner name="crescent" /> : 'Save'}
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent
className="ion-padding"
style={{ '--background': 'var(--color-bg)' }}
>
{/* Photo Manager */}
<div style={{ marginBottom: '24px' }}>
<h3
style={{
fontSize: '13px',
fontWeight: 800,
color: 'var(--color-text-tertiary)',
textTransform: 'uppercase',
marginBottom: '12px',
}}
>
Profile Photos
</h3>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '10px',
}}
>
{photos.map((photo) => (
<div
key={photo.id}
style={{
paddingBottom: '125%',
background: 'var(--color-surface-raised)',
borderRadius: '12px',
position: 'relative',
overflow: 'hidden',
}}
>
<img
src={
getStorageUrl('profile-photos', photo.storage_path) || ''
}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
alt="Profile"
/>
<button
onClick={() => deletePhoto(photo.id, photo.storage_path)}
style={{
position: 'absolute',
top: '8px',
right: '8px',
background: 'rgba(0,0,0,0.5)',
border: 'none',
borderRadius: '50%',
width: '24px',
height: '24px',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon icon={trashOutline} style={{ fontSize: '14px' }} />
</button>
</div>
))}
{photos.length < 6 && (
<label
style={{
paddingBottom: '125%',
background: 'var(--color-surface)',
borderRadius: '12px',
position: 'relative',
border: '2px dashed var(--ion-color-primary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{uploading ? (
<IonSpinner name="crescent" />
) : (
<IonIcon
icon={cameraOutline}
style={{
fontSize: '24px',
color: 'var(--ion-color-primary)',
}}
/>
)}
</div>
<input
type="file"
accept="image/*"
onChange={handlePhotoUpload}
hidden
disabled={uploading}
/>
</label>
)}
</div>
</div>
{/* Basic Info */}
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '16px',
boxShadow: 'var(--shadow-card)',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="Full Name"
labelPlacement="floating"
value={fullName}
onIonInput={(e) => setFullName(e.detail.value!)}
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Gender"
labelPlacement="floating"
value={gender}
onIonChange={(e) => setGender(e.detail.value)}
interface="action-sheet"
>
<IonSelectOption value="woman">Woman</IonSelectOption>
<IonSelectOption value="man">Man</IonSelectOption>
<IonSelectOption value="nonbinary">Non-binary</IonSelectOption>
<IonSelectOption value="other">Other</IonSelectOption>
<IonSelectOption value="prefer_not_to_say">
Prefer not to say
</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Relationship Goal"
labelPlacement="floating"
value={relationshipGoal}
onIonChange={(e) => setRelationshipGoal(e.detail.value)}
interface="action-sheet"
>
{goals.map((g) => (
<IonSelectOption key={g.value} value={g.value}>
{g.label}
</IonSelectOption>
))}
</IonSelect>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonTextarea
label="Bio"
labelPlacement="floating"
value={bio}
onIonInput={(e) => setBio(e.detail.value!)}
placeholder="Tell us about yourself..."
rows={4}
/>
</IonItem>
</div>
</IonContent>
</IonPage>
);
};
export default EditProfilePage;
+153
View File
@@ -0,0 +1,153 @@
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;
-623
View File
@@ -1,623 +0,0 @@
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&apos;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;
+558
View File
@@ -0,0 +1,558 @@
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;
+22 -485
View File
@@ -1,493 +1,30 @@
import React, { import React from "react";
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { import {
IonButton,
IonButtons,
IonContent, IonContent,
IonHeader, IonHeader,
IonIcon,
IonPage, IonPage,
IonRefresher, IonTitle,
IonRefresherContent,
IonSkeletonText,
IonToolbar, 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';
interface HabitWithMeta extends Tables<'habits'> { const Home: React.FC = () => (
completions: Tables<'habit_completions'>[]; <IonPage>
} <IonHeader translucent className="home-header">
<IonToolbar className="home-toolbar">
const formatDate = (date: Date) => date.toISOString().slice(0, 10); <IonTitle>Home</IonTitle>
</IonToolbar>
const calculateCurrentStreak = (dates: string[]) => { </IonHeader>
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a)); <IonContent fullscreen className="home-content">
let streak = 0; <div className="home-shell minimal-home-shell">
let cursor = new Date(); <section className="minimal-welcome-card subtle-welcome-card">
<h1>Welcome to your new app</h1>
if (!unique.includes(formatDate(cursor))) { <p className="minimal-subtitle">
cursor.setDate(cursor.getDate() - 1); Chat with the assistant to start adding features and pages
} </p>
</section>
while (unique.includes(formatDate(cursor))) { </div>
streak += 1; </IonContent>
cursor.setDate(cursor.getDate() - 1); </IonPage>
} );
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&apos;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; export default Home;
+699
View File
@@ -0,0 +1,699 @@
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;

Some files were not shown because too many files have changed in this diff Show More