Compare commits

..

1 Commits

Author SHA1 Message Date
AppCakes 4c09b353ee build: 3d326e8d-e3da-4f61-941d-6fd836a5b391 2026-06-01 11:48:20 +00:00
117 changed files with 592 additions and 7416 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"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.inspot.app"
applicationId "io.demoapp.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
+1 -12
View File
@@ -1,11 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
@@ -30,15 +25,9 @@
<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" />
<data android:scheme="io.demoapp.app" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.inspot.app" />
</intent-filter>
</activity>
<meta-data
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 742 B

After

Width:  |  Height:  |  Size: 804 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 B

After

Width:  |  Height:  |  Size: 771 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 742 B

After

Width:  |  Height:  |  Size: 804 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="splashBackground">#FFFFFF</color>
</resources>
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">InSpot</string>
<string name="title_activity_main">InSpot</string>
<string name="app_name">DemoApp</string>
<string name="title_activity_main">DemoApp</string>
<string name="package_name">io.ionic.starter</string>
<string name="custom_url_scheme">io.ionic.starter</string>
</resources>
+3 -1
View File
@@ -17,6 +17,8 @@
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
<item name="windowSplashScreenBackground">@color/splashBackground</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_logo</item>
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
</style>
</resources>
+2 -2
View File
@@ -1,8 +1,8 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.inspot.app',
appName: 'InSpot',
appId: 'io.demoapp.app',
appName: 'DemoApp',
webDir: 'dist',
plugins: {
FirebaseAuthentication: {
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;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.inspot.app;
PRODUCT_BUNDLE_IDENTIFIER = io.demoapp.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
@@ -330,7 +330,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.inspot.app;
PRODUCT_BUNDLE_IDENTIFIER = io.demoapp.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
+1 -18
View File
@@ -1,7 +1,5 @@
import UIKit
import Capacitor
import FirebaseCore
import FirebaseMessaging
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
@@ -9,25 +7,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FirebaseApp.configure()
// Override point for customization after application launch.
return true
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
Messaging.messaging().token { token, error in
if let error = error {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
} else if let token = token {
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: token)
}
}
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
}
func applicationWillResignActive(_ application: UIApplication) {
// 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 480 B

After

Width:  |  Height:  |  Size: 477 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 B

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 979 B

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+7 -17
View File
@@ -1,23 +1,13 @@
{
"images" : [
"images": [
{
"idiom" : "universal",
"filename" : "splash-2732x2732-2.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732-1.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732.png",
"scale" : "3x"
"idiom": "universal",
"filename": "splash.png",
"scale": "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
"info": {
"author": "appsuite",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

+3 -22
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>InSpot</string>
<string>DemoApp</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -51,31 +51,12 @@
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.inspot.app</string>
<string>io.demoapp.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.inspot.app</string>
<string>io.demoapp.app</string>
</array>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.inspot.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.inspot.app</string>
</array>
</dict>
</array>
<key>NSCameraUsageDescription</key>
<string>InSpot needs camera access to let you capture and share reels of local spots.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>InSpot needs photo library access to let you upload profile photos and share reels from your gallery.</string>
<key>NSMicrophoneUsageDescription</key>
<string>InSpot needs microphone access to record sound for your reels.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>InSpot needs your location to discover nearby spots and social connections.</string>
</dict>
</plist>
+363
View File
@@ -0,0 +1,363 @@
workflows:
android-debug:
name: Android Debug APK
max_build_duration: 45
instance_type: mac_mini_m2
environment:
node: 22
java: 21
cache:
cache_paths:
- $HOME/.gradle/caches
- $HOME/.gradle/wrapper
scripts:
- name: Strip unused scaffold packages
script: |
for pkg in "@capacitor/google-maps" "@capacitor/push-notifications" "@capacitor-firebase/authentication"; do
if ! grep -rq "$pkg" src/; then
node -e "const fs=require('fs'),p=JSON.parse(fs.readFileSync('package.json','utf8'));delete p.dependencies['${pkg}'];fs.writeFileSync('package.json',JSON.stringify(p,null,2));"
echo "Stripped $pkg (not used in src/)"
else
echo "Keeping $pkg (used in src/)"
fi
done
- name: Install dependencies
script: rm -f package-lock.json && npm install --legacy-peer-deps
- name: Build web assets
script: npm run build
- name: Write Firebase config
script: |
if [ -n "$GOOGLE_SERVICES_JSON" ]; then
echo "$GOOGLE_SERVICES_JSON" | base64 --decode > android/app/google-services.json
echo "Wrote google-services.json ($(wc -c < android/app/google-services.json) bytes)"
else
echo "GOOGLE_SERVICES_JSON is empty — skipping"
fi
if [ -n "$GOOGLE_SERVICE_INFO_PLIST" ]; then
echo "$GOOGLE_SERVICE_INFO_PLIST" | base64 --decode > ios/App/App/GoogleService-Info.plist
echo "Wrote GoogleService-Info.plist ($(wc -c < ios/App/App/GoogleService-Info.plist) bytes)"
fi
- name: Capacitor sync
script: npx cap sync android
- name: Set up debug keystore
script: |
if [ -n "$CM_DEBUG_KEYSTORE" ]; then
echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12
fi
- name: Build debug APK
script: |
cd android
if [ -n "$CM_DEBUG_KEYSTORE" ]; then
./gradlew assembleDebug \
-Pandroid.injected.signing.store.file=/tmp/debug-keystore.p12 \
-Pandroid.injected.signing.store.password=$CM_DEBUG_KEYSTORE_PASSWORD \
-Pandroid.injected.signing.key.alias=debug \
-Pandroid.injected.signing.key.password=$CM_DEBUG_KEYSTORE_PASSWORD \
--no-daemon
else
./gradlew assembleDebug --no-daemon
fi
artifacts:
- android/app/build/outputs/apk/debug/app-debug.apk
android-release-apk:
name: Android Release APK
max_build_duration: 45
instance_type: mac_mini_m2
environment:
node: 22
java: 21
cache:
cache_paths:
- $HOME/.gradle/caches
- $HOME/.gradle/wrapper
scripts:
- name: Strip unused scaffold packages
script: |
for pkg in "@capacitor/google-maps" "@capacitor/push-notifications" "@capacitor-firebase/authentication"; do
if ! grep -rq "$pkg" src/; then
node -e "const fs=require('fs'),p=JSON.parse(fs.readFileSync('package.json','utf8'));delete p.dependencies['${pkg}'];fs.writeFileSync('package.json',JSON.stringify(p,null,2));"
echo "Stripped $pkg (not used in src/)"
else
echo "Keeping $pkg (used in src/)"
fi
done
- name: Install dependencies
script: rm -f package-lock.json && npm install --legacy-peer-deps
- name: Build web assets
script: npm run build
- name: Write Firebase config
script: |
if [ -n "$GOOGLE_SERVICES_JSON" ]; then
echo "$GOOGLE_SERVICES_JSON" | base64 --decode > android/app/google-services.json
fi
- name: Capacitor sync
script: npx cap sync android
- name: Set up keystore
script: echo "$CM_KEYSTORE" | base64 --decode > /tmp/keystore.jks
- name: Build Release APK
script: |
cd android
./gradlew assembleRelease \
-Pandroid.injected.signing.store.file=/tmp/keystore.jks \
-Pandroid.injected.signing.store.password=$CM_KEYSTORE_PASSWORD \
-Pandroid.injected.signing.key.alias=$CM_KEY_ALIAS \
-Pandroid.injected.signing.key.password=$CM_KEY_PASSWORD \
--no-daemon
artifacts:
- android/app/build/outputs/apk/release/app-release.apk
android-release-aab:
name: Android Release AAB
max_build_duration: 45
instance_type: mac_mini_m2
environment:
node: 22
java: 21
cache:
cache_paths:
- $HOME/.gradle/caches
- $HOME/.gradle/wrapper
scripts:
- name: Strip unused scaffold packages
script: |
for pkg in "@capacitor/google-maps" "@capacitor/push-notifications" "@capacitor-firebase/authentication"; do
if ! grep -rq "$pkg" src/; then
node -e "const fs=require('fs'),p=JSON.parse(fs.readFileSync('package.json','utf8'));delete p.dependencies['${pkg}'];fs.writeFileSync('package.json',JSON.stringify(p,null,2));"
echo "Stripped $pkg (not used in src/)"
else
echo "Keeping $pkg (used in src/)"
fi
done
- name: Install dependencies
script: rm -f package-lock.json && npm install --legacy-peer-deps
- name: Build web assets
script: npm run build
- name: Write Firebase config
script: |
if [ -n "$GOOGLE_SERVICES_JSON" ]; then
echo "$GOOGLE_SERVICES_JSON" | base64 --decode > android/app/google-services.json
fi
- name: Capacitor sync
script: npx cap sync android
- name: Set up keystore
script: echo "$CM_KEYSTORE" | base64 --decode > /tmp/keystore.jks
- name: Build Release AAB
script: |
cd android
./gradlew bundleRelease \
-Pandroid.injected.signing.store.file=/tmp/keystore.jks \
-Pandroid.injected.signing.store.password=$CM_KEYSTORE_PASSWORD \
-Pandroid.injected.signing.key.alias=$CM_KEY_ALIAS \
-Pandroid.injected.signing.key.password=$CM_KEY_PASSWORD \
--no-daemon
artifacts:
- android/app/build/outputs/bundle/release/app-release.aab
android-release:
name: Android Publish to Google Play
max_build_duration: 60
instance_type: mac_mini_m2
environment:
node: 22
java: 21
cache:
cache_paths:
- $HOME/.gradle/caches
- $HOME/.gradle/wrapper
scripts:
- name: Strip unused scaffold packages
script: |
for pkg in "@capacitor/google-maps" "@capacitor/push-notifications" "@capacitor-firebase/authentication"; do
if ! grep -rq "$pkg" src/; then
node -e "const fs=require('fs'),p=JSON.parse(fs.readFileSync('package.json','utf8'));delete p.dependencies['${pkg}'];fs.writeFileSync('package.json',JSON.stringify(p,null,2));"
echo "Stripped $pkg (not used in src/)"
else
echo "Keeping $pkg (used in src/)"
fi
done
- name: Install dependencies
script: rm -f package-lock.json && npm install --legacy-peer-deps
- name: Build web assets
script: npm run build
- name: Write Firebase config
script: |
if [ -n "$GOOGLE_SERVICES_JSON" ]; then
echo "$GOOGLE_SERVICES_JSON" | base64 --decode > android/app/google-services.json
fi
- name: Capacitor sync
script: npx cap sync android
- name: Set up keystore
script: echo "$CM_KEYSTORE" | base64 --decode > /tmp/keystore.jks
- name: Build Release AAB
script: |
cd android
./gradlew bundleRelease \
-Pandroid.injected.signing.store.file=/tmp/keystore.jks \
-Pandroid.injected.signing.store.password=$CM_KEYSTORE_PASSWORD \
-Pandroid.injected.signing.key.alias=$CM_KEY_ALIAS \
-Pandroid.injected.signing.key.password=$CM_KEY_PASSWORD \
--no-daemon
publishing:
google_play:
credentials: $GOOGLE_PLAY_SERVICE_ACCOUNT_CREDENTIALS
track: $GOOGLE_PLAY_TRACK
submit_as_draft: false
artifacts:
- android/app/build/outputs/bundle/release/app-release.aab
ios-debug:
name: iOS Debug IPA
max_build_duration: 60
instance_type: mac_mini_m2
environment:
node: 22
xcode: latest
scripts:
- name: Strip unused scaffold packages
script: |
for pkg in "@capacitor/google-maps" "@capacitor/push-notifications" "@capacitor-firebase/authentication"; do
if ! grep -rq "$pkg" src/; then
node -e "const fs=require('fs'),p=JSON.parse(fs.readFileSync('package.json','utf8'));delete p.dependencies['${pkg}'];fs.writeFileSync('package.json',JSON.stringify(p,null,2));"
echo "Stripped $pkg (not used in src/)"
else
echo "Keeping $pkg (used in src/)"
fi
done
- name: Install dependencies
script: rm -f package-lock.json && npm install --legacy-peer-deps
- name: Build web assets
script: npm run build
- name: Capacitor sync
script: npx cap sync ios
- name: Write Firebase config
script: |
if [ -n "$GOOGLE_SERVICE_INFO_PLIST" ]; then
echo "$GOOGLE_SERVICE_INFO_PLIST" | base64 --decode > ios/App/App/GoogleService-Info.plist
ruby -e "
require 'xcodeproj'
project = Xcodeproj::Project.open('ios/App/App.xcodeproj')
target = project.targets.find { |t| t.name == 'App' }
app_group = project.main_group['App']
unless app_group.files.find { |f| f.path == 'GoogleService-Info.plist' }
file_ref = app_group.new_reference('GoogleService-Info.plist')
target.resources_build_phase.add_file_reference(file_ref)
project.save
end
"
fi
- name: Capacitor sync
script: npx cap sync ios
- name: Set up signing
script: |
keychain initialize
if [ -n "$APP_STORE_CONNECT_PRIVATE_KEY" ]; then
app-store-connect fetch-signing-files \
$(xcode-project detect-bundle-id) \
--type IOS_APP_DEVELOPMENT \
--certificate-key=@env:APP_STORE_CONNECT_PRIVATE_KEY \
--issuer-id=$APP_STORE_CONNECT_ISSUER_ID \
--key-id=$APP_STORE_CONNECT_KEY_IDENTIFIER \
--create
xcode-project use-profiles
elif [ -n "$CM_CERTIFICATE" ]; then
echo "$CM_CERTIFICATE" | base64 --decode > /tmp/cert.p12
keychain add-certificates \
--certificate /tmp/cert.p12 \
--certificate-password "$CM_CERTIFICATE_PASSWORD"
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
echo "$CM_PROVISIONING_PROFILE" | base64 --decode > "$HOME/Library/MobileDevice/Provisioning Profiles/profile.mobileprovision"
xcode-project use-profiles
else
echo "No signing credentials configured — build will fail at signing"
exit 1
fi
- name: Build IPA
script: |
xcode-project build-ipa \
--workspace ios/App/App.xcworkspace \
--scheme App
artifacts:
- build/ios/ipa/*.ipa
ios-release:
name: iOS Release IPA
max_build_duration: 90
instance_type: mac_mini_m2
environment:
node: 22
xcode: latest
scripts:
- name: Strip unused scaffold packages
script: |
for pkg in "@capacitor/google-maps" "@capacitor/push-notifications" "@capacitor-firebase/authentication"; do
if ! grep -rq "$pkg" src/; then
node -e "const fs=require('fs'),p=JSON.parse(fs.readFileSync('package.json','utf8'));delete p.dependencies['${pkg}'];fs.writeFileSync('package.json',JSON.stringify(p,null,2));"
echo "Stripped $pkg (not used in src/)"
else
echo "Keeping $pkg (used in src/)"
fi
done
- name: Install dependencies
script: rm -f package-lock.json && npm install --legacy-peer-deps
- name: Build web assets
script: npm run build
- name: Capacitor sync
script: npx cap sync ios
- name: Write Firebase config
script: |
if [ -n "$GOOGLE_SERVICE_INFO_PLIST" ]; then
echo "$GOOGLE_SERVICE_INFO_PLIST" | base64 --decode > ios/App/App/GoogleService-Info.plist
ruby -e "
require 'xcodeproj'
project = Xcodeproj::Project.open('ios/App/App.xcodeproj')
target = project.targets.find { |t| t.name == 'App' }
app_group = project.main_group['App']
unless app_group.files.find { |f| f.path == 'GoogleService-Info.plist' }
file_ref = app_group.new_reference('GoogleService-Info.plist')
target.resources_build_phase.add_file_reference(file_ref)
project.save
end
"
fi
- name: Capacitor sync
script: npx cap sync ios
- name: Set up signing
script: |
keychain initialize
if [ -n "$APP_STORE_CONNECT_PRIVATE_KEY" ]; then
app-store-connect fetch-signing-files \
$(xcode-project detect-bundle-id) \
--type IOS_APP_STORE \
--certificate-key=@env:APP_STORE_CONNECT_PRIVATE_KEY \
--issuer-id=$APP_STORE_CONNECT_ISSUER_ID \
--key-id=$APP_STORE_CONNECT_KEY_IDENTIFIER \
--create
xcode-project use-profiles
else
echo "$CM_CERTIFICATE" | base64 --decode > /tmp/cert.p12
keychain add-certificates \
--certificate /tmp/cert.p12 \
--certificate-password "$CM_CERTIFICATE_PASSWORD"
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
echo "$CM_PROVISIONING_PROFILE" | base64 --decode > "$HOME/Library/MobileDevice/Provisioning Profiles/profile.mobileprovision"
xcode-project use-profiles
fi
- name: Build IPA
script: |
xcode-project build-ipa \
--workspace ios/App/App.xcworkspace \
--scheme App \
--config Release
- name: Publish
script: |
if [ "$SUBMIT_TO_TESTFLIGHT" = "true" ] || [ "$SUBMIT_TO_APP_STORE" = "true" ]; then
app-store-connect publish \
--certificate-key=@env:APP_STORE_CONNECT_PRIVATE_KEY \
--issuer-id=$APP_STORE_CONNECT_ISSUER_ID \
--key-id=$APP_STORE_CONNECT_KEY_IDENTIFIER \
--submit-to-testflight=$SUBMIT_TO_TESTFLIGHT \
--submit-to-app-store=$SUBMIT_TO_APP_STORE
fi
artifacts:
- build/ios/ipa/*.ipa
+1 -3
View File
@@ -20,15 +20,13 @@
"@capacitor/keyboard": "8.0.3",
"@capacitor/push-notifications": "^8.1.1",
"@capacitor/status-bar": "8.0.2",
"@hookform/resolvers": "^3.9.0",
"@ionic/pwa-elements": "^3.0.0",
"@ionic/react": "8.8.7",
"@ionic/react-router": "8.8.7",
"@supabase/supabase-js": "^2.108.1",
"@supabase/supabase-js": "^2.0.0",
"firebase": "^11.0.0",
"ionicons": "^7.4.0",
"katex": "^0.16.0",
"lottie-react": "^2.4.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.54.0",
BIN
View File
Binary file not shown.
+13 -127
View File
@@ -1,29 +1,13 @@
import React from 'react';
import React, { useEffect } from 'react';
import { Redirect, Route } from 'react-router-dom';
import {
IonApp,
IonIcon,
IonLabel,
IonRouterOutlet,
IonTabBar,
IonTabButton,
IonTabs,
} from '@ionic/react';
import { IonApp, IonRouterOutlet } from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router';
import {
flameOutline,
locationOutline,
chatbubblesOutline,
personOutline,
} from 'ionicons/icons';
import { setStatusBarStyle, Style } from './utils/statusBar';
/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';
import '@ionic/react/css/normalize.css';
import '@ionic/react/css/structure.css';
import '@ionic/react/css/typography.css';
/* Optional CSS utils that can be commented out */
import '@ionic/react/css/padding.css';
import '@ionic/react/css/float-elements.css';
import '@ionic/react/css/text-alignment.css';
@@ -31,123 +15,25 @@ import '@ionic/react/css/text-transformation.css';
import '@ionic/react/css/flex-utils.css';
import '@ionic/react/css/display.css';
/* Theme variables */
import './theme/variables.css';
/* Pages */
import AuthPage from './pages/AuthPage';
import VerifyEmailPage from './pages/VerifyEmailPage';
import ForgotPasswordPage from './pages/ForgotPasswordPage';
import NewPasswordPage from './pages/NewPasswordPage';
import OnboardingProfilePage from './pages/OnboardingProfilePage';
import IcebreakerPage from './pages/IcebreakerPage';
import HangoutPage from './pages/HangoutPage';
import ReelsPage from './pages/ReelsPage';
import CameraPage from './pages/CameraPage';
import ChatsPage from './pages/ChatsPage';
import ChatThreadPage from './pages/ChatThreadPage';
import ProfilePage from './pages/ProfilePage';
import EditProfilePage from './pages/EditProfilePage';
import NotificationsPage from './pages/NotificationsPage';
import PremiumPage from './pages/PremiumPage';
import Home from './pages/Home';
/* Contexts & Components */
import { AuthProvider } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import { isPreviewMode } from './utils/previewMode';
const App: React.FC = () => {
useEffect(() => {
setStatusBarStyle(Style.Light);
}, []);
const App: React.FC = () => (
return (
<IonApp>
<AuthProvider>
<IonReactRouter>
<IonRouterOutlet>
{/* Auth Stack */}
<Route path="/auth" component={AuthPage} exact />
<Route path="/verify-email" component={VerifyEmailPage} exact />
<Route path="/forgot-password" component={ForgotPasswordPage} exact />
<Route path="/new-password" component={NewPasswordPage} exact />
<ProtectedRoute
path="/onboarding/profile"
component={OnboardingProfilePage}
exact
/>
{/* Detailed Stack (No Tabs) */}
<ProtectedRoute path="/reels/:placeId" component={ReelsPage} exact />
<ProtectedRoute
path="/chat/:conversationId"
component={ChatThreadPage}
exact
/>
<ProtectedRoute
path="/profile/edit"
component={EditProfilePage}
exact
/>
<ProtectedRoute path="/premium" component={PremiumPage} exact />
<ProtectedRoute
path="/notifications"
component={NotificationsPage}
exact
/>
<ProtectedRoute path="/camera" component={CameraPage} exact />
{/* Main Tabs */}
<Route
path={['/icebreaker', '/hangout', '/chats', '/profile']}
render={() => (
<IonTabs>
<IonRouterOutlet>
<ProtectedRoute
path="/icebreaker"
component={IcebreakerPage}
exact
/>
<ProtectedRoute
path="/hangout"
component={HangoutPage}
exact
/>
<ProtectedRoute path="/chats" component={ChatsPage} exact />
<ProtectedRoute
path="/profile"
component={ProfilePage}
exact
/>
</IonRouterOutlet>
<IonTabBar slot="bottom">
<IonTabButton tab="icebreaker" href="/icebreaker">
<IonIcon icon={flameOutline} />
<IonLabel>Icebreaker</IonLabel>
</IonTabButton>
<IonTabButton tab="hangout" href="/hangout">
<IonIcon icon={locationOutline} />
<IonLabel>Hangout</IonLabel>
</IonTabButton>
<IonTabButton tab="chats" href="/chats">
<IonIcon icon={chatbubblesOutline} />
<IonLabel>Chats</IonLabel>
</IonTabButton>
<IonTabButton tab="profile" href="/profile">
<IonIcon icon={personOutline} />
<IonLabel>Profile</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
)}
/>
<Route
exact
path="/"
render={() => (
<Redirect to={isPreviewMode() ? '/icebreaker' : '/auth'} />
)}
/>
<Route path="/home" component={Home} exact />
<Route exact path="/" render={() => <Redirect to="/home" />} />
</IonRouterOutlet>
</IonReactRouter>
</AuthProvider>
</IonApp>
);
);
};
export default App;
View File
Binary file not shown.
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

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

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

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

Before

Width:  |  Height:  |  Size: 148 KiB

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

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

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

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

-70
View File
@@ -1,70 +0,0 @@
import React from 'react';
import { getStorageUrl, getInitials, getInitialsColor } from '../utils/storage';
interface AvatarProps {
src?: string | null;
name?: string | null;
size?: number;
bucket?: string;
className?: string;
style?: React.CSSProperties;
}
const Avatar: React.FC<AvatarProps> = ({
src,
name,
size = 48,
bucket = 'avatars',
className = '',
style = {},
}) => {
const url =
src?.startsWith('/src/assets/') || src?.startsWith('/assets/')
? src
: getStorageUrl(bucket, src);
const containerStyle: React.CSSProperties = {
width: size,
height: size,
borderRadius: '50%',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
background: url ? 'transparent' : getInitialsColor(name || null),
...style,
};
if (url) {
return (
<div className={className} style={containerStyle}>
<img
src={url}
alt={name || 'Avatar'}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
);
}
return (
<div className={className} style={containerStyle}>
<span
style={{
color: '#ffffff',
fontSize: size * 0.4,
fontWeight: 700,
letterSpacing: '-0.02em',
}}
>
{getInitials(name || null)}
</span>
</div>
);
};
export default Avatar;
-84
View File
@@ -1,84 +0,0 @@
import React from 'react';
import { IonButton, IonIcon } from '@ionic/react';
interface EmptyStateProps {
icon: string;
title: string;
message: string;
ctaText?: string;
onCtaClick?: () => void;
style?: React.CSSProperties;
}
const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
message,
ctaText,
onCtaClick,
style = {},
}) => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 20px',
textAlign: 'center',
minHeight: '300px',
...style,
}}
>
<div
style={{
width: '80px',
height: '80px',
borderRadius: '24px',
background: 'var(--color-surface-raised)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '20px',
}}
>
<IonIcon
icon={icon}
style={{ fontSize: '40px', color: 'var(--ion-color-primary)' }}
/>
</div>
<h3
style={{
fontSize: '20px',
fontWeight: 700,
margin: '0 0 8px',
color: 'var(--color-text-primary)',
}}
>
{title}
</h3>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: '0 0 24px',
lineHeight: 1.5,
}}
>
{message}
</p>
{ctaText && onCtaClick && (
<IonButton
expand="block"
onClick={onCtaClick}
style={{ '--border-radius': 'var(--radius-pill)', minWidth: '200px' }}
>
{ctaText}
</IonButton>
)}
</div>
);
};
export default EmptyState;
-97
View File
@@ -1,97 +0,0 @@
import React, { useRef, useEffect } from 'react';
interface OtpInputProps {
value: string;
onChange: (value: string) => void;
length?: number;
disabled?: boolean;
}
const OtpInput: React.FC<OtpInputProps> = ({
value,
onChange,
length = 6,
disabled = false,
}) => {
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement>,
index: number
) => {
const val = e.target.value;
if (!val) return;
const newOtp = value.split('');
newOtp[index] = val.slice(-1);
const combined = newOtp.join('');
onChange(combined);
if (val && index < length - 1) {
inputRefs.current[index + 1]?.focus();
}
};
const handleKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>,
index: number
) => {
if (e.key === 'Backspace' && !value[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const pastedData = e.clipboardData.getData('text').slice(0, length);
if (/^\d+$/.test(pastedData)) {
onChange(pastedData);
inputRefs.current[Math.min(pastedData.length, length - 1)]?.focus();
}
};
return (
<div
style={{
display: 'flex',
gap: '8px',
justifyContent: 'center',
margin: '24px 0',
}}
>
{Array.from({ length }).map((_, i) => (
<input
key={i}
ref={(el) => {
inputRefs.current[i] = el;
}}
type="text"
inputMode="numeric"
pattern="\d*"
maxLength={1}
value={value[i] || ''}
onChange={(e) => handleChange(e, i)}
onKeyDown={(e) => handleKeyDown(e, i)}
onPaste={handlePaste}
disabled={disabled}
style={{
width: '44px',
height: '52px',
textAlign: 'center',
fontSize: '22px',
fontWeight: 700,
borderRadius: '12px',
border: '2px solid transparent',
background: 'var(--color-surface)',
color: 'var(--color-text-primary)',
outline: 'none',
transition: 'border-color 0.2s',
...(value[i] ? { borderColor: 'var(--ion-color-primary)' } : {}),
}}
/>
))}
</div>
);
};
export default OtpInput;
-27
View File
@@ -1,27 +0,0 @@
import React from 'react';
import { Route, Redirect, RouteProps } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { isPreviewMode } from '../utils/previewMode';
interface ProtectedRouteProps extends RouteProps {
component: React.ComponentType<any>;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
component: Component,
...rest
}) => {
const { user } = useAuth();
const previewMode = isPreviewMode();
return (
<Route
{...rest}
render={(props) =>
user || previewMode ? <Component {...props} /> : <Redirect to="/auth" />
}
/>
);
};
export default ProtectedRoute;
-68
View File
@@ -1,68 +0,0 @@
import React from 'react';
import { IonSearchbar, IonButton, IonIcon } from '@ionic/react';
import { optionsOutline } from 'ionicons/icons';
interface SearchRowProps {
value: string;
onChange: (e: any) => void;
onFilterClick?: () => void;
placeholder?: string;
}
const SearchRow: React.FC<SearchRowProps> = ({
value,
onChange,
onFilterClick,
placeholder = 'Search...',
}) => {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
margin: '0 20px 14px',
}}
>
<IonSearchbar
value={value}
onIonInput={onChange}
placeholder={placeholder}
style={{
padding: 0,
'--border-radius': 'var(--radius-pill)',
'--background': 'rgba(255,255,255,0.06)',
'--color': 'var(--color-text-primary)',
'--placeholder-color': 'rgba(255,255,255,0.45)',
'--icon-color': 'rgba(255,255,255,0.75)',
'--clear-button-color': 'rgba(255,255,255,0.55)',
}}
/>
{onFilterClick && (
<IonButton
onClick={onFilterClick}
style={{
'--background': 'rgba(255,255,255,0.06)',
'--color': 'var(--color-text-primary)',
'--border-radius': '12px',
width: '44px',
height: '44px',
margin: 0,
flexShrink: 0,
'--padding-start': 0,
'--padding-end': 0,
'--box-shadow': 'none',
}}
>
<IonIcon
icon={optionsOutline}
slot="icon-only"
style={{ fontSize: '20px' }}
/>
</IonButton>
)}
</div>
);
};
export default SearchRow;
-107
View File
@@ -1,107 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { User, Session } from '@supabase/supabase-js';
import { supabase } from '../supabase';
import { Database } from '../database.types';
import { IonSpinner } from '@ionic/react';
type Profile = Database['public']['Tables']['profiles']['Row'];
interface AuthContextType {
user: User | null;
session: Session | null;
profile: Profile | null;
loading: boolean;
refreshProfile: () => Promise<void>;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true);
const refreshProfile = async (userId?: string) => {
const id = userId || user?.id;
if (!id) return;
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', id)
.single();
if (data) {
setProfile(data);
} else if (error && error.code !== 'PGRST116') {
console.error('Error fetching profile:', error);
}
};
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
if (session?.user) {
refreshProfile(session.user.id).finally(() => setLoading(false));
} else {
setLoading(false);
}
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
setUser(session?.user ?? null);
if (session?.user) {
refreshProfile(session.user.id);
} else {
setProfile(null);
}
setLoading(false);
});
return () => subscription.unsubscribe();
}, []);
const signOut = async () => {
await supabase.auth.signOut();
};
if (loading) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
background: 'var(--color-bg)',
}}
>
<IonSpinner name="crescent" color="primary" />
</div>
);
}
return (
<AuthContext.Provider
value={{ user, session, profile, loading, refreshProfile, signOut }}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
-758
View File
@@ -1,758 +0,0 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "14.5"
}
public: {
Tables: {
check_ins: {
Row: {
checked_in_at: string | null
expires_at: string
id: string
place_id: string
user_id: string
}
Insert: {
checked_in_at?: string | null
expires_at: string
id?: string
place_id: string
user_id: string
}
Update: {
checked_in_at?: string | null
expires_at?: string
id?: string
place_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "check_ins_place_id_fkey"
columns: ["place_id"]
isOneToOne: false
referencedRelation: "places"
referencedColumns: ["id"]
},
{
foreignKeyName: "check_ins_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
conversations: {
Row: {
created_at: string | null
id: string
match_id: string
updated_at: string | null
}
Insert: {
created_at?: string | null
id?: string
match_id: string
updated_at?: string | null
}
Update: {
created_at?: string | null
id?: string
match_id?: string
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "conversations_match_id_fkey"
columns: ["match_id"]
isOneToOne: true
referencedRelation: "matches"
referencedColumns: ["id"]
},
]
}
discovery_preferences: {
Row: {
goals: string[]
max_age: number
max_distance_km: number
min_age: number
updated_at: string | null
user_id: string
}
Insert: {
goals?: string[]
max_age?: number
max_distance_km?: number
min_age?: number
updated_at?: string | null
user_id: string
}
Update: {
goals?: string[]
max_age?: number
max_distance_km?: number
min_age?: number
updated_at?: string | null
user_id?: string
}
Relationships: [
{
foreignKeyName: "discovery_preferences_user_id_fkey"
columns: ["user_id"]
isOneToOne: true
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
matches: {
Row: {
created_at: string | null
id: string
last_message_at: string | null
user_a: string
user_b: string
}
Insert: {
created_at?: string | null
id?: string
last_message_at?: string | null
user_a: string
user_b: string
}
Update: {
created_at?: string | null
id?: string
last_message_at?: string | null
user_a?: string
user_b?: string
}
Relationships: [
{
foreignKeyName: "matches_user_a_fkey"
columns: ["user_a"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "matches_user_b_fkey"
columns: ["user_b"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
messages: {
Row: {
body: string
conversation_id: string
created_at: string | null
id: string
read_at: string | null
sender_id: string
}
Insert: {
body: string
conversation_id: string
created_at?: string | null
id?: string
read_at?: string | null
sender_id: string
}
Update: {
body?: string
conversation_id?: string
created_at?: string | null
id?: string
read_at?: string | null
sender_id?: string
}
Relationships: [
{
foreignKeyName: "messages_conversation_id_fkey"
columns: ["conversation_id"]
isOneToOne: false
referencedRelation: "conversations"
referencedColumns: ["id"]
},
{
foreignKeyName: "messages_sender_id_fkey"
columns: ["sender_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
notifications: {
Row: {
body: string
created_at: string | null
deep_link: string | null
id: string
read_at: string | null
title: string
type: string
user_id: string
}
Insert: {
body: string
created_at?: string | null
deep_link?: string | null
id?: string
read_at?: string | null
title: string
type: string
user_id: string
}
Update: {
body?: string
created_at?: string | null
deep_link?: string | null
id?: string
read_at?: string | null
title?: string
type?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "notifications_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
payments: {
Row: {
amount: number
created_at: string | null
currency: string
gateway: string
id: string
metadata: Json
reference: string
status: string
updated_at: string | null
user_id: string
}
Insert: {
amount: number
created_at?: string | null
currency?: string
gateway?: string
id?: string
metadata?: Json
reference: string
status?: string
updated_at?: string | null
user_id: string
}
Update: {
amount?: number
created_at?: string | null
currency?: string
gateway?: string
id?: string
metadata?: Json
reference?: string
status?: string
updated_at?: string | null
user_id?: string
}
Relationships: []
}
places: {
Row: {
address: string | null
busyness: string
category: string
created_at: string | null
created_by: string | null
description: string | null
google_place_id: string | null
id: string
image_path: string | null
latitude: number
longitude: number
name: string
rating: number
review_count: number
updated_at: string | null
}
Insert: {
address?: string | null
busyness?: string
category?: string
created_at?: string | null
created_by?: string | null
description?: string | null
google_place_id?: string | null
id?: string
image_path?: string | null
latitude: number
longitude: number
name: string
rating?: number
review_count?: number
updated_at?: string | null
}
Update: {
address?: string | null
busyness?: string
category?: string
created_at?: string | null
created_by?: string | null
description?: string | null
google_place_id?: string | null
id?: string
image_path?: string | null
latitude?: number
longitude?: number
name?: string
rating?: number
review_count?: number
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "places_created_by_fkey"
columns: ["created_by"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
profile_photos: {
Row: {
created_at: string | null
id: string
is_primary: boolean
position: number
storage_path: string
user_id: string
}
Insert: {
created_at?: string | null
id?: string
is_primary?: boolean
position?: number
storage_path: string
user_id: string
}
Update: {
created_at?: string | null
id?: string
is_primary?: boolean
position?: number
storage_path?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "profile_photos_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
profiles: {
Row: {
avatar_path: string | null
bio: string | null
birth_date: string | null
city: string | null
created_at: string | null
fcm_token: string | null
full_name: string
gender: string | null
id: string
interests: string[]
latitude: number | null
longitude: number | null
onboarding_complete: boolean
relationship_goal: string | null
updated_at: string | null
}
Insert: {
avatar_path?: string | null
bio?: string | null
birth_date?: string | null
city?: string | null
created_at?: string | null
fcm_token?: string | null
full_name?: string
gender?: string | null
id: string
interests?: string[]
latitude?: number | null
longitude?: number | null
onboarding_complete?: boolean
relationship_goal?: string | null
updated_at?: string | null
}
Update: {
avatar_path?: string | null
bio?: string | null
birth_date?: string | null
city?: string | null
created_at?: string | null
fcm_token?: string | null
full_name?: string
gender?: string | null
id?: string
interests?: string[]
latitude?: number | null
longitude?: number | null
onboarding_complete?: boolean
relationship_goal?: string | null
updated_at?: string | null
}
Relationships: []
}
reel_comments: {
Row: {
body: string
created_at: string | null
id: string
reel_id: string
user_id: string
}
Insert: {
body: string
created_at?: string | null
id?: string
reel_id: string
user_id: string
}
Update: {
body?: string
created_at?: string | null
id?: string
reel_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "reel_comments_reel_id_fkey"
columns: ["reel_id"]
isOneToOne: false
referencedRelation: "reels"
referencedColumns: ["id"]
},
{
foreignKeyName: "reel_comments_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
reel_likes: {
Row: {
created_at: string | null
reel_id: string
user_id: string
}
Insert: {
created_at?: string | null
reel_id: string
user_id: string
}
Update: {
created_at?: string | null
reel_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "reel_likes_reel_id_fkey"
columns: ["reel_id"]
isOneToOne: false
referencedRelation: "reels"
referencedColumns: ["id"]
},
{
foreignKeyName: "reel_likes_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
reels: {
Row: {
author_id: string
caption: string
comments_count: number
created_at: string | null
id: string
likes_count: number
media_path: string
media_type: string
place_id: string
shares_count: number
thumbnail_path: string | null
}
Insert: {
author_id: string
caption?: string
comments_count?: number
created_at?: string | null
id?: string
likes_count?: number
media_path: string
media_type: string
place_id: string
shares_count?: number
thumbnail_path?: string | null
}
Update: {
author_id?: string
caption?: string
comments_count?: number
created_at?: string | null
id?: string
likes_count?: number
media_path?: string
media_type?: string
place_id?: string
shares_count?: number
thumbnail_path?: string | null
}
Relationships: [
{
foreignKeyName: "reels_author_id_fkey"
columns: ["author_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "reels_place_id_fkey"
columns: ["place_id"]
isOneToOne: false
referencedRelation: "places"
referencedColumns: ["id"]
},
]
}
subscriptions: {
Row: {
active_until: string | null
tier: string
updated_at: string | null
user_id: string
}
Insert: {
active_until?: string | null
tier?: string
updated_at?: string | null
user_id: string
}
Update: {
active_until?: string | null
tier?: string
updated_at?: string | null
user_id?: string
}
Relationships: []
}
swipes: {
Row: {
action: string
created_at: string | null
id: string
swiped_id: string
swiper_id: string
}
Insert: {
action: string
created_at?: string | null
id?: string
swiped_id: string
swiper_id: string
}
Update: {
action?: string
created_at?: string | null
id?: string
swiped_id?: string
swiper_id?: string
}
Relationships: [
{
foreignKeyName: "swipes_swiped_id_fkey"
columns: ["swiped_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "swipes_swiper_id_fkey"
columns: ["swiper_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {},
},
} as const
+5 -10
View File
@@ -5,7 +5,7 @@ import { Capacitor } from '@capacitor/core';
import App from './App';
// ── Ionic initialisation ──────────────────────────────────────────────────────
// ?mode=md|ios overrides (used by Appcakes preview); otherwise follow the platform.
// ?mode=md|ios overrides (used by AppSuite preview); otherwise follow the platform.
const _urlMode = new URLSearchParams(window.location.search).get('mode');
const _platform = Capacitor.getPlatform(); // 'ios' | 'android' | 'web'
setupIonicReact({
@@ -13,7 +13,7 @@ setupIonicReact({
});
// Derive the studio parent origin dynamically so this works in both dev
// (parent at localhost:3000) and production (parent at studio.appcakes.dev).
// (parent at localhost:3000) and production (parent at appcakes.qqura.com).
const studioOrigin = (() => {
try {
if (document.referrer) return new URL(document.referrer).origin;
@@ -49,7 +49,7 @@ if (import.meta.hot) {
}
// ── Session bridge ────────────────────────────────────────────────────────────
// Post auth session to the Appcakes parent frame so the AI can test auth-protected
// Post auth session to the AppSuite parent frame so the AI can test auth-protected
// Edge Functions. Uses import.meta.glob so Vite never errors if supabase.ts is absent.
(async () => {
const mods = import.meta.glob('./supabase.ts', { eager: false });
@@ -73,17 +73,12 @@ if (import.meta.hot) {
})();
// ── Runtime error reporting ───────────────────────────────────────────────────
// Forward uncaught errors to the Appcakes dev server so the AI can read them.
// Extract projectId from the preview URL (/preview/{projectId}/) so errors are
// scoped per-project on the server rather than mixed into a global queue.
const _previewMatch = window.location.pathname.match(/\/preview\/([^/]+)/);
const _projectId = _previewMatch?.[1] ?? null;
// Forward uncaught errors to the AppSuite dev server so the AI can read them.
function reportError(message: string, stack?: string) {
fetch(`${studioOrigin}/api/agent/runtime-error`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, stack, projectId: _projectId }),
body: JSON.stringify({ message, stack }),
}).catch(() => {});
}
-425
View File
@@ -1,425 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonInput,
IonItem,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonRouterLink,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { mail, lockClosed, logoFacebook } from 'ionicons/icons';
import { IonIcon } from '@ionic/react';
import { enablePreviewMode } from '../utils/previewMode';
import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { Capacitor } from '@capacitor/core';
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
const AuthPage: React.FC = () => {
const history = useHistory();
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleAuth = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
if (mode === 'signin') {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
if (error.message.toLowerCase().includes('email not confirmed')) {
await supabase.auth.resend({ type: 'signup', email });
history.push('/verify-email', { email, type: 'signup' });
return;
}
throw error;
}
history.push('/icebreaker');
} else {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: { onboarding_complete: false },
},
});
if (error) throw error;
if (data.session) {
history.push('/onboarding/profile');
} else {
history.push('/verify-email', { email, type: 'signup' });
}
}
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
const enterPreviewMode = () => {
enablePreviewMode();
history.push('/icebreaker');
};
const handleSocialSignIn = async (provider: 'google' | 'apple') => {
if (!Capacitor.isNativePlatform()) {
showError(
`${provider === 'google' ? 'Google' : 'Apple'} Sign-In is only available on native devices.`
);
return;
}
setLoading(true);
try {
let result;
if (provider === 'google') {
result = await FirebaseAuthentication.signInWithGoogle();
} else {
result = await FirebaseAuthentication.signInWithApple({
skipNativeAuth: true,
});
}
const { error } = await supabase.auth.signInWithIdToken({
provider,
token: result.credential!.idToken!,
nonce: result.credential?.nonce,
});
if (error) throw error;
history.push('/icebreaker');
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
className="ion-padding"
style={{
'--background': 'var(--color-bg)',
display: 'flex',
flexDirection: 'column',
paddingTop: 'calc(var(--ion-safe-area-top) + 40px)',
}}
>
<div style={{ padding: '0 20px' }}>
<div style={{ marginBottom: '44px', paddingTop: '24px' }}>
<h1
style={{
fontSize: '28px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 42px',
}}
>
{mode === 'signin' ? 'Hi there, 👋' : 'Create account'}
</h1>
<h2
style={{
fontSize: '24px',
fontWeight: 700,
color: 'var(--color-text-primary)',
margin: '0 0 12px',
}}
>
{mode === 'signin'
? 'Sign in to continue'
: 'Sign up to continue'}
</h2>
<p
style={{
fontSize: '16px',
color: 'var(--color-text-secondary)',
margin: 0,
lineHeight: 1.35,
}}
>
{mode === 'signin'
? "Don't have an account? Enter your email and password or choose one of the options below."
: 'Enter your email and password to start discovering people and places nearby.'}
</p>
</div>
<div
style={{
display: 'flex',
background: 'var(--color-surface-raised)',
padding: '4px',
borderRadius: 'var(--radius-pill)',
marginBottom: '20px',
}}
>
{(['signin', 'signup'] as const).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
style={{
flex: 1,
padding: '10px',
borderRadius: 'var(--radius-pill)',
border: 'none',
fontSize: '14px',
fontWeight: 700,
transition: '0.2s',
background:
mode === m ? 'var(--ion-color-primary)' : 'transparent',
color: mode === m ? '#ffffff' : 'var(--color-text-primary)',
}}
>
{m === 'signin' ? 'Sign In' : 'Sign Up'}
</button>
))}
</div>
<form
onSubmit={handleAuth}
style={{
background: 'transparent',
padding: '0',
borderRadius: '0',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-input)',
'--border-radius': 'var(--radius-control)',
'--border-width': '1px',
'--border-style': 'solid',
'--border-color': 'var(--color-text-secondary)',
'--min-height': '48px',
}}
>
<IonIcon
icon={mail}
slot="start"
style={{ color: 'var(--ion-color-primary)', fontSize: '20px' }}
/>
<IonInput
label="Email"
labelPlacement="floating"
type="email"
value={email}
onIonInput={(e) => setEmail(e.detail.value!)}
required
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-input)',
'--border-radius': 'var(--radius-control)',
'--border-width': '1px',
'--border-style': 'solid',
'--border-color': 'var(--color-text-secondary)',
'--min-height': '48px',
}}
>
<IonIcon
icon={lockClosed}
slot="start"
style={{ color: 'var(--ion-color-primary)', fontSize: '20px' }}
/>
<IonInput
label="Password"
labelPlacement="floating"
type="password"
value={password}
onIonInput={(e) => setPassword(e.detail.value!)}
required
/>
</IonItem>
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
}}
>
{error}
</div>
)}
<IonButton
type="submit"
expand="block"
disabled={loading}
style={{
'--border-radius': 'var(--radius-pill)',
'--color': '#000000',
fontWeight: 800,
height: '48px',
margin: '8px 28px 0',
}}
>
{loading ? (
<IonSpinner name="crescent" />
) : mode === 'signin' ? (
'Sign in'
) : (
'Continue'
)}
</IonButton>
{mode === 'signin' && (
<IonRouterLink
routerLink="/forgot-password"
style={{
textAlign: 'center',
fontSize: '13px',
color: 'var(--ion-color-primary)',
marginTop: '8px',
}}
>
Forgot password?
</IonRouterLink>
)}
</form>
<div
style={{
textAlign: 'center',
margin: '56px 0 26px',
fontSize: '13px',
color: 'var(--color-text-tertiary)',
}}
>
OR
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
padding: '0 28px',
}}
>
<IonButton
expand="block"
onClick={() => handleSocialSignIn('apple')}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
style={{ marginRight: '10px' }}
>
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
Sign in with Apple
</IonButton>
<IonButton
expand="block"
onClick={() =>
showError(
'Facebook Sign-In is only available on native devices.'
)
}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<IonIcon
icon={logoFacebook}
style={{ marginRight: '10px', fontSize: '22px' }}
/>
Sign in with Facebook
</IonButton>
<IonButton
expand="block"
onClick={() => handleSocialSignIn('google')}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<span
style={{
fontSize: '24px',
fontWeight: 900,
marginRight: '12px',
}}
>
G
</span>
Sign in with Google
</IonButton>
<IonButton
expand="block"
onClick={enterPreviewMode}
style={{
flex: 1,
'--background': 'var(--ion-color-primary)',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
Preview with dummy data
</IonButton>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default AuthPage;
-311
View File
@@ -1,311 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonIcon,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonItem,
IonTextarea,
IonSelect,
IonSelectOption,
} from '@ionic/react';
import {
closeOutline,
cameraOutline,
imageOutline,
sendOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const CameraPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [caption, setCaption] = useState('');
const [placeId, setPlaceId] = useState<string | null>(null);
const [places, setPlaces] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [isPublishOpen, setIsPublishOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchPlaces();
});
const fetchPlaces = async () => {
const { data } = await supabase.from('places').select('id, name');
setPlaces(data || []);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewUrl(URL.createObjectURL(selectedFile));
setIsPublishOpen(true);
}
};
const handlePublish = async () => {
if (!file || !user || !placeId) {
setError('Please select a place and add a file.');
return;
}
setUploading(true);
try {
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
const filePath = `${fileName}`;
// 1. Upload to storage
const { error: uploadError } = await supabase.storage
.from('reels')
.upload(filePath, file);
if (uploadError) throw uploadError;
// 2. Create reel record
const { error: reelError } = await supabase.from('reels').insert({
author_id: user.id,
place_id: placeId,
caption,
media_path: filePath,
media_type: file.type.startsWith('video') ? 'video' : 'image',
});
if (reelError) throw reelError;
history.replace(`/reels/${placeId}`);
} catch (err: any) {
setError(err.message);
} finally {
setUploading(false);
}
};
return (
<IonPage>
<IonContent fullscreen style={{ '--background': '#000000' }}>
<div
style={{
position: 'absolute',
top: 'calc(14px + var(--ion-safe-area-top))',
left: '20px',
zIndex: 10,
}}
>
<IonButton
fill="clear"
onClick={() => history.goBack()}
style={{
'--background': 'rgba(0,0,0,0.3)',
'--color': '#ffffff',
'--border-radius': '50%',
width: '40px',
height: '40px',
margin: 0,
}}
>
<IonIcon icon={closeOutline} slot="icon-only" />
</IonButton>
</div>
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
}}
>
<div
style={{
width: '120px',
height: '120px',
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '24px',
}}
>
<IonIcon
icon={cameraOutline}
style={{ fontSize: '48px', opacity: 0.5 }}
/>
</div>
<h2
style={{ fontSize: '20px', fontWeight: 800, marginBottom: '8px' }}
>
Create a Reel
</h2>
<p
style={{
opacity: 0.6,
fontSize: '14px',
marginBottom: '32px',
textAlign: 'center',
padding: '0 40px',
}}
>
Share your experience at a local spot. Select a photo or video to
begin.
</p>
<label
style={{
background: 'var(--ion-color-primary)',
color: '#ffffff',
padding: '14px 32px',
borderRadius: 'var(--radius-pill)',
fontWeight: 700,
fontSize: '15px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<IonIcon icon={imageOutline} style={{ fontSize: '20px' }} />
Select Media
<input
type="file"
accept="image/*,video/*"
onChange={handleFileChange}
hidden
/>
</label>
</div>
{/* Publish Modal */}
<IonModal
isOpen={isPublishOpen}
onDidDismiss={() => {
setIsPublishOpen(false);
setPreviewUrl(null);
setFile(null);
}}
>
<IonHeader>
<IonToolbar>
<IonTitle>New Reel</IonTitle>
<IonButtons slot="start">
<IonButton onClick={() => setIsPublishOpen(false)}>
Cancel
</IonButton>
</IonButtons>
<IonButtons slot="end">
<IonButton
onClick={handlePublish}
disabled={uploading || !placeId}
>
{uploading ? <IonSpinner name="crescent" /> : 'Share'}
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
<div
style={{
width: '100px',
height: '140px',
borderRadius: '12px',
overflow: 'hidden',
background: '#000',
flexShrink: 0,
}}
>
{file?.type.startsWith('video') ? (
<video
src={previewUrl || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<img
src={previewUrl || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
alt="Preview"
/>
)}
</div>
<IonTextarea
placeholder="Write a caption..."
value={caption}
onIonInput={(e) => setCaption(e.detail.value!)}
rows={6}
style={{
'--background': 'var(--color-bg)',
'--padding-start': '12px',
'--border-radius': '12px',
}}
/>
</div>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Tag a place"
labelPlacement="floating"
placeholder="Select place"
value={placeId}
onIonChange={(e) => setPlaceId(e.detail.value)}
>
{places.map((p) => (
<IonSelectOption key={p.id} value={p.id}>
{p.name}
</IonSelectOption>
))}
</IonSelect>
</IonItem>
{error && (
<div
style={{
color: 'var(--ion-color-danger)',
fontSize: '13px',
marginTop: '12px',
padding: '0 4px',
}}
>
{error}
</div>
)}
</IonContent>
</IonModal>
</IonContent>
</IonPage>
);
};
export default CameraPage;
-263
View File
@@ -1,263 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonInput,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { chevronBackOutline, sendOutline } from 'ionicons/icons';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import Avatar from '../components/Avatar';
import { previewChats, previewMessages } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const ChatThreadPage: React.FC = () => {
const history = useHistory();
const { conversationId } = useParams<{ conversationId: string }>();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [messages, setMessages] = useState<any[]>([]);
const [otherUser, setOtherUser] = useState<any>(null);
const [text, setText] = useState('');
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLIonContentElement>(null);
const fetchThread = async () => {
setLoading(true);
if (previewMode) {
const chat =
previewChats.find((item) => item.id === conversationId) ||
previewChats[0];
setOtherUser(chat.otherUser);
setMessages(previewMessages[chat.id] || []);
setLoading(false);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
return;
}
if (!user) return;
try {
// 1. Get other participant
const { data: conv } = await supabase
.from('conversations')
.select(
`
match_id,
matches(
user_a:profiles!matches_user_a_fkey(id, full_name, avatar_path),
user_b:profiles!matches_user_b_fkey(id, full_name, avatar_path)
)
`
)
.eq('id', conversationId)
.single();
if (conv) {
const match = (conv as any).matches;
setOtherUser(match.user_a.id === user.id ? match.user_b : match.user_a);
}
// 2. Get messages
const { data: msgs } = await supabase
.from('messages')
.select('*')
.eq('conversation_id', conversationId)
.order('created_at', { ascending: true });
setMessages(msgs || []);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
} catch (err: any) {
console.error('Fetch thread error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchThread();
});
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim() || (!user && !previewMode) || sending) return;
setSending(true);
const msgBody = text.trim();
setText('');
if (previewMode) {
const previewMessage = {
id: `preview-message-${Date.now()}`,
sender_id: 'preview-me',
body: msgBody,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, previewMessage]);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
setSending(false);
return;
}
try {
const { data, error } = await supabase
.from('messages')
.insert({
conversation_id: conversationId,
sender_id: user.id,
body: msgBody,
})
.select()
.single();
if (error) throw error;
setMessages((prev) => [...prev, data]);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
// Invoke notification
supabase.functions.invoke('send-message-notification', {
body: { conversationId, messageId: data.id },
});
} catch (err: any) {
console.error('Send error:', err);
} finally {
setSending(false);
}
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
<IonButtons slot="start">
<IonButton
onClick={() => history.goBack()}
style={{ '--color': 'var(--color-text-primary)' }}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle>
{otherUser && (
<div
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
<Avatar
src={otherUser.avatar_path}
name={otherUser.full_name}
size={32}
/>
<span style={{ fontSize: '16px', fontWeight: 700 }}>
{otherUser.full_name}
</span>
</div>
)}
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent
ref={scrollRef}
style={{ '--background': 'var(--color-bg)' }}
className="ion-padding"
>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
paddingBottom: '20px',
}}
>
{messages.map((msg) => {
const isMine = previewMode
? msg.sender_id === 'preview-me'
: msg.sender_id === user?.id;
return (
<div
key={msg.id}
style={{
alignSelf: isMine ? 'flex-end' : 'flex-start',
maxWidth: '78%',
background: isMine
? 'var(--ion-color-primary)'
: 'var(--color-surface)',
color: isMine ? '#ffffff' : 'var(--color-text-primary)',
padding: '10px 14px',
borderRadius: isMine
? '18px 18px 4px 18px'
: '18px 18px 18px 4px',
fontSize: '14px',
lineHeight: 1.4,
boxShadow: '0 2px 8px rgba(76, 5, 25, 0.02)',
}}
>
{msg.body}
</div>
);
})}
</div>
)}
</IonContent>
<div
style={{
background: '#ffffff',
padding: '10px 20px calc(10px + var(--ion-safe-area-bottom))',
borderTop: '1px solid var(--color-border)',
}}
>
<form
onSubmit={handleSend}
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
>
<IonInput
value={text}
onIonInput={(e) => setText(e.detail.value!)}
placeholder="Type a message..."
style={{
background: 'var(--color-bg)',
'--border-radius': 'var(--radius-pill)',
'--padding-start': '16px',
fontSize: '14px',
}}
/>
<IonButton
type="submit"
disabled={!text.trim() || sending}
style={{
'--border-radius': '50%',
width: '44px',
height: '44px',
margin: 0,
flexShrink: 0,
}}
>
{sending ? (
<IonSpinner name="crescent" />
) : (
<IonIcon icon={sendOutline} slot="icon-only" />
)}
</IonButton>
</form>
</div>
</IonPage>
);
};
export default ChatThreadPage;
-218
View File
@@ -1,218 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonSpinner,
useIonViewWillEnter,
IonRefresher,
IonRefresherContent,
IonIcon,
} from '@ionic/react';
import { chatbubbleEllipsesOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { formatTime } from '../utils/format';
import SearchRow from '../components/SearchRow';
import Avatar from '../components/Avatar';
import EmptyState from '../components/EmptyState';
import { previewChats } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const ChatsPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [chats, setChats] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const fetchChats = async () => {
if (previewMode) {
setChats(previewChats);
setLoading(false);
return;
}
if (!user) return;
setLoading(true);
try {
const { data, error } = await supabase
.from('matches')
.select(
`
id,
last_message_at,
user_a:profiles!matches_user_a_fkey(id, full_name, avatar_path),
user_b:profiles!matches_user_b_fkey(id, full_name, avatar_path),
conversations(
id,
messages(
body,
created_at
)
)
`
)
.or(`user_a.eq.${user.id},user_b.eq.${user.id}`)
.order('last_message_at', { ascending: false });
if (error) throw error;
const formatted =
data?.map((m) => {
const otherUser = m.user_a.id === user.id ? m.user_b : m.user_a;
const conversation = m.conversations?.[0];
const lastMsg = conversation?.messages?.[0]; // ordered by handle-swipe or trigger later
return {
id: conversation?.id,
otherUser,
lastMessage: lastMsg?.body || 'New match! Start chatting.',
timestamp: m.last_message_at || lastMsg?.created_at,
};
}) || [];
setChats(formatted);
} catch (err: any) {
console.error('Fetch chats error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchChats();
});
const handleRefresh = async (event: CustomEvent) => {
await fetchChats();
event.detail.complete();
};
const displayChats = previewMode ? previewChats : chats;
const filteredChats = displayChats.filter(
(c) =>
c.otherUser.full_name.toLowerCase().includes(query.toLowerCase()) ||
c.lastMessage.toLowerCase().includes(query.toLowerCase())
);
return (
<IonPage>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div
style={{ padding: 'calc(16px + var(--ion-safe-area-top)) 20px 12px' }}
>
<h1
style={{
fontSize: '24px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: 0,
}}
>
Chats
</h1>
</div>
<SearchRow
value={query}
onChange={(e) => setQuery(e.detail.value!)}
placeholder="Search conversations"
/>
<div
style={{ paddingBottom: 'calc(92px + var(--ion-safe-area-bottom))' }}
>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : filteredChats.length === 0 ? (
<EmptyState
icon={chatbubbleEllipsesOutline}
title="No chats yet"
message="Matches you message will appear here. Start swiping to find someone!"
ctaText="Find people"
onCtaClick={() => history.push('/icebreaker')}
/>
) : (
filteredChats.map((chat) => (
<div
key={chat.id}
onClick={() => history.push(`/chat/${chat.id}`)}
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
margin: '0 20px 12px',
padding: '16px',
display: 'flex',
alignItems: 'center',
gap: '12px',
boxShadow: 'var(--shadow-card)',
}}
>
<Avatar
src={chat.otherUser.image || chat.otherUser.avatar_path}
name={chat.otherUser.full_name}
size={56}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '4px',
}}
>
<h3
style={{
fontSize: '16px',
fontWeight: 800,
margin: 0,
color: 'var(--color-text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{chat.otherUser.full_name}
</h3>
<span
style={{
fontSize: '11px',
color: 'var(--color-text-tertiary)',
fontWeight: 600,
}}
>
{formatTime(chat.timestamp)}
</span>
</div>
<p
style={{
fontSize: '14px',
color: 'var(--color-text-secondary)',
margin: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{chat.lastMessage}
</p>
</div>
</div>
))
)}
</div>
</IonContent>
</IonPage>
);
};
export default ChatsPage;
-378
View File
@@ -1,378 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonItem,
IonInput,
IonTextarea,
IonSelect,
IonSelectOption,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import {
chevronBackOutline,
cameraOutline,
trashOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { getStorageUrl } from '../utils/storage';
const EditProfilePage: React.FC = () => {
const history = useHistory();
const { user, profile, refreshProfile } = useAuth();
const [fullName, setFullName] = useState(profile?.full_name || '');
const [bio, setBio] = useState(profile?.bio || '');
const [gender, setGender] = useState(profile?.gender || '');
const [relationshipGoal, setRelationshipGoal] = useState(
profile?.relationship_goal || ''
);
const [loading, setLoading] = useState(false);
const [photos, setPhotos] = useState<any[]>([]);
const [uploading, setUploading] = useState(false);
const fetchPhotos = async () => {
if (!user) return;
const { data } = await supabase
.from('profile_photos')
.select('*')
.eq('user_id', user.id)
.order('position');
setPhotos(data || []);
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
if (profile) {
setFullName(profile.full_name);
setBio(profile.bio || '');
setGender(profile.gender || '');
setRelationshipGoal(profile.relationship_goal || '');
}
fetchPhotos();
});
const handleSave = async () => {
if (!user) return;
setLoading(true);
try {
const { error } = await supabase
.from('profiles')
.update({
full_name: fullName,
bio,
gender,
relationship_goal: relationshipGoal,
})
.eq('id', user.id);
if (error) throw error;
await refreshProfile();
history.goBack();
} catch (err: any) {
console.error('Save error:', err);
} finally {
setLoading(false);
}
};
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !user) return;
setUploading(true);
try {
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
const filePath = `profile-photos/${fileName}`;
const { error: uploadError } = await supabase.storage
.from('profile-photos')
.upload(filePath, file);
if (uploadError) throw uploadError;
const { error: dbError } = await supabase.from('profile_photos').insert({
user_id: user.id,
storage_path: filePath,
position: photos.length,
});
if (dbError) throw dbError;
fetchPhotos();
} catch (err: any) {
console.error('Photo upload error:', err);
} finally {
setUploading(false);
}
};
const deletePhoto = async (id: string, path: string) => {
try {
await supabase.from('profile_photos').delete().eq('id', id);
await supabase.storage.from('profile-photos').remove([path]);
fetchPhotos();
} catch (err) {
console.error('Delete photo error:', err);
}
};
const goals = [
{ label: 'Long-term partner', value: 'long_term' },
{ label: 'Long-term, but short-term OK', value: 'long_term_short_ok' },
{ label: 'Short-term, but long-term OK', value: 'short_term_long_ok' },
{ label: 'Short-term fun', value: 'short_term' },
{ label: 'New friends', value: 'friends' },
{ label: 'Still figuring it out', value: 'figuring_it_out' },
];
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
<IonButtons slot="start">
<IonButton
onClick={() => history.goBack()}
style={{ '--color': 'var(--color-text-primary)' }}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle>Edit Profile</IonTitle>
<IonButtons slot="end">
<IonButton
onClick={handleSave}
disabled={loading}
style={{ fontWeight: 700 }}
>
{loading ? <IonSpinner name="crescent" /> : 'Save'}
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent
className="ion-padding"
style={{ '--background': 'var(--color-bg)' }}
>
{/* Photo Manager */}
<div style={{ marginBottom: '24px' }}>
<h3
style={{
fontSize: '13px',
fontWeight: 800,
color: 'var(--color-text-tertiary)',
textTransform: 'uppercase',
marginBottom: '12px',
}}
>
Profile Photos
</h3>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '10px',
}}
>
{photos.map((photo) => (
<div
key={photo.id}
style={{
paddingBottom: '125%',
background: 'var(--color-surface-raised)',
borderRadius: '12px',
position: 'relative',
overflow: 'hidden',
}}
>
<img
src={
getStorageUrl('profile-photos', photo.storage_path) || ''
}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
alt="Profile"
/>
<button
onClick={() => deletePhoto(photo.id, photo.storage_path)}
style={{
position: 'absolute',
top: '8px',
right: '8px',
background: 'rgba(0,0,0,0.5)',
border: 'none',
borderRadius: '50%',
width: '24px',
height: '24px',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon icon={trashOutline} style={{ fontSize: '14px' }} />
</button>
</div>
))}
{photos.length < 6 && (
<label
style={{
paddingBottom: '125%',
background: 'var(--color-surface)',
borderRadius: '12px',
position: 'relative',
border: '2px dashed var(--ion-color-primary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{uploading ? (
<IonSpinner name="crescent" />
) : (
<IonIcon
icon={cameraOutline}
style={{
fontSize: '24px',
color: 'var(--ion-color-primary)',
}}
/>
)}
</div>
<input
type="file"
accept="image/*"
onChange={handlePhotoUpload}
hidden
disabled={uploading}
/>
</label>
)}
</div>
</div>
{/* Basic Info */}
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '16px',
boxShadow: 'var(--shadow-card)',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="Full Name"
labelPlacement="floating"
value={fullName}
onIonInput={(e) => setFullName(e.detail.value!)}
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Gender"
labelPlacement="floating"
value={gender}
onIonChange={(e) => setGender(e.detail.value)}
interface="action-sheet"
>
<IonSelectOption value="woman">Woman</IonSelectOption>
<IonSelectOption value="man">Man</IonSelectOption>
<IonSelectOption value="nonbinary">Non-binary</IonSelectOption>
<IonSelectOption value="other">Other</IonSelectOption>
<IonSelectOption value="prefer_not_to_say">
Prefer not to say
</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Relationship Goal"
labelPlacement="floating"
value={relationshipGoal}
onIonChange={(e) => setRelationshipGoal(e.detail.value)}
interface="action-sheet"
>
{goals.map((g) => (
<IonSelectOption key={g.value} value={g.value}>
{g.label}
</IonSelectOption>
))}
</IonSelect>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonTextarea
label="Bio"
labelPlacement="floating"
value={bio}
onIonInput={(e) => setBio(e.detail.value!)}
placeholder="Tell us about yourself..."
rows={4}
/>
</IonItem>
</div>
</IonContent>
</IonPage>
);
};
export default EditProfilePage;
-153
View File
@@ -1,153 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonInput,
IonItem,
IonButton,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const ForgotPasswordPage: React.FC = () => {
const history = useHistory();
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleReset = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const { error } = await supabase.auth.resetPasswordForEmail(email);
if (error) throw error;
history.push('/verify-email', { email, type: 'recovery' });
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background': 'var(--color-bg)',
}}
>
<div style={{ padding: 'calc(var(--ion-safe-area-top) + 40px) 20px' }}>
<div style={{ marginBottom: '32px' }}>
<h1
style={{
fontSize: '28px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 8px',
}}
>
Reset Password
</h1>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: 0,
lineHeight: 1.5,
}}
>
Enter your email and we'll send you a code to reset your password.
</p>
</div>
<form
onSubmit={handleReset}
style={{
background: 'var(--color-surface)',
padding: '24px',
borderRadius: 'var(--radius-card)',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="Email"
labelPlacement="floating"
type="email"
value={email}
onIonInput={(e) => setEmail(e.detail.value!)}
required
/>
</IonItem>
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
}}
>
{error}
</div>
)}
<IonButton
type="submit"
expand="block"
disabled={loading}
style={{
'--border-radius': 'var(--radius-pill)',
height: '48px',
marginTop: '8px',
}}
>
{loading ? <IonSpinner name="crescent" /> : 'Send Code'}
</IonButton>
<button
type="button"
onClick={() => history.goBack()}
style={{
background: 'none',
border: 'none',
color: 'var(--color-text-tertiary)',
fontSize: '14px',
fontWeight: 600,
marginTop: '12px',
}}
>
Back to Sign In
</button>
</form>
</div>
</IonContent>
</IonPage>
);
};
export default ForgotPasswordPage;
-558
View File
@@ -1,558 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonIcon,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonRefresher,
IonRefresherContent,
} from '@ionic/react';
import {
locationOutline,
notificationsOutline,
star,
cameraOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { formatDistance } from '../utils/format';
import { getStorageUrl } from '../utils/storage';
import SearchRow from '../components/SearchRow';
import EmptyState from '../components/EmptyState';
import { previewPlaces } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const HangoutPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [places, setPlaces] = useState<any[]>([]);
const [reelStories, setReelStories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const [sort, setSort] = useState<'most_busy' | 'nearest' | 'top_rated'>(
'most_busy'
);
const fetchPlaces = async () => {
setLoading(true);
if (previewMode) {
setPlaces(previewPlaces);
setReelStories(previewPlaces);
setLoading(false);
return;
}
try {
// Phase 1: simple local query. Phase 2: use places-nearby Edge Function
const { data, error } = await supabase
.from('places')
.select(
`
*,
reels(id)
`
)
.order('rating', { ascending: false });
if (error) throw error;
setPlaces(data || []);
// Derive stories (places with reels)
const stories =
data?.filter((p) => p.reels?.length > 0).slice(0, 10) || [];
setReelStories(stories);
} catch (err: any) {
console.error('Fetch places error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchPlaces();
});
const handleRefresh = async (event: CustomEvent) => {
await fetchPlaces();
event.detail.complete();
};
const displayPlaces = previewMode ? previewPlaces : places;
const displayStories = previewMode ? previewPlaces : reelStories;
const filteredPlaces = displayPlaces.filter(
(p) =>
p.name.toLowerCase().includes(query.toLowerCase()) ||
p.category.toLowerCase().includes(query.toLowerCase())
);
return (
<IonPage>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div
style={{
padding: 'calc(14px + var(--ion-safe-area-top)) 20px 8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '12px',
background: 'rgba(255,255,255,0.06)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={locationOutline}
style={{ color: 'var(--ion-color-primary)', fontSize: '18px' }}
/>
</div>
<div
style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}
>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--color-text-tertiary)',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}
>
Nearby now
</span>
<span
style={{
fontSize: '17px',
fontWeight: 700,
color: 'var(--color-text-primary)',
lineHeight: 1.1,
}}
>
Midrand, ZA
</span>
</div>
</div>
<IonButton
fill="clear"
onClick={() => history.push('/notifications')}
style={{
'--background': 'rgba(255,255,255,0.06)',
'--color': 'var(--color-text-primary)',
'--border-radius': '12px',
width: '44px',
height: '44px',
margin: 0,
'--padding-start': 0,
'--padding-end': 0,
}}
>
<IonIcon
icon={notificationsOutline}
slot="icon-only"
style={{ fontSize: '20px' }}
/>
</IonButton>
</div>
<SearchRow
value={query}
onChange={(e) => setQuery(e.detail.value!)}
placeholder="Search place of interest"
/>
<div
style={{
display: 'flex',
gap: '10px',
padding: '2px 20px 26px',
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}
>
{(['most_busy', 'nearest', 'top_rated'] as const).map((s) => {
const active = sort === s;
return (
<button
key={s}
onClick={() => setSort(s)}
style={{
padding: '10px 16px',
borderRadius: 'var(--radius-pill)',
border: 'none',
fontSize: '14px',
fontWeight: active ? 700 : 600,
whiteSpace: 'nowrap',
background: active
? 'linear-gradient(180deg, #f24f88 0%, var(--ion-color-primary) 100%)'
: 'rgba(255,255,255,0.05)',
color: active ? '#ffffff' : 'rgba(255,255,255,0.82)',
boxShadow: active
? '0 10px 24px rgba(254, 60, 114, 0.26)'
: 'none',
transition: '0.2s ease',
flexShrink: 0,
}}
>
{s.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
</button>
);
})}
</div>
{/* Reels Stories Section */}
<div style={{ marginBottom: '26px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0 20px 14px',
}}
>
<h2
style={{
fontSize: '18px',
fontWeight: 800,
margin: 0,
color: 'var(--color-text-primary)',
letterSpacing: '-0.02em',
}}
>
Reels
</h2>
<button
onClick={() => history.push('/camera')}
style={{
width: '44px',
height: '44px',
borderRadius: '12px',
border: 'none',
background:
'linear-gradient(180deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.06) 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
flexShrink: 0,
}}
>
<IonIcon
icon={cameraOutline}
style={{ fontSize: '20px', color: 'var(--color-text-primary)' }}
/>
</button>
</div>
<div
style={{
display: 'flex',
gap: '14px',
padding: '0 20px',
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}
>
<div style={{ flexShrink: 0, width: '76px', textAlign: 'center' }}>
<div
onClick={() => history.push('/camera')}
style={{
width: '68px',
height: '68px',
borderRadius: '50%',
padding: '2px',
background:
'linear-gradient(180deg, #f24f88 0%, #a60b4a 100%)',
marginBottom: '8px',
}}
>
<div
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
background: '#08080a',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon
icon={cameraOutline}
style={{
fontSize: '22px',
color: 'var(--ion-color-primary)',
}}
/>
</div>
</div>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--color-text-primary)',
opacity: 0.95,
}}
>
My reels
</span>
</div>
{displayStories.map((story) => (
<div
key={story.id}
onClick={() => history.push(`/reels/${story.id}`)}
style={{ flexShrink: 0, width: '76px', textAlign: 'center' }}
>
<div
style={{
width: '68px',
height: '68px',
borderRadius: '50%',
padding: '2px',
background:
'linear-gradient(180deg, #f24f88 0%, #a60b4a 100%)',
marginBottom: '8px',
}}
>
<div
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
padding: '3px',
background: '#08080a',
}}
>
<img
src={
story.image ||
getStorageUrl('place-media', story.image_path) ||
''
}
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
objectFit: 'cover',
}}
alt={story.name}
/>
</div>
</div>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--color-text-primary)',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
opacity: 0.95,
}}
>
{story.name}
</span>
</div>
))}
</div>
</div>
{/* Places List */}
<div
style={{ paddingBottom: 'calc(92px + var(--ion-safe-area-bottom))' }}
>
<h2
style={{
fontSize: '18px',
fontWeight: 800,
margin: '0 20px 16px',
color: 'var(--color-text-primary)',
}}
>
Places of interest 📍
</h2>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : filteredPlaces.length === 0 ? (
<EmptyState
icon={locationOutline}
title="No places found"
message="We couldn't find any places matching your search nearby."
ctaText="Refresh"
onCtaClick={fetchPlaces}
/>
) : (
filteredPlaces.map((place, index) => (
<div
key={place.id}
onClick={() => history.push(`/reels/${place.id}`)}
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
margin: '0 20px 12px',
padding: '12px',
display: 'flex',
gap: '12px',
boxShadow: 'var(--shadow-card)',
}}
>
<div
style={{
flexShrink: 0,
width: '112px',
height: '88px',
borderRadius: 'var(--radius-control)',
overflow: 'hidden',
}}
>
<img
src={
place.image ||
getStorageUrl('place-media', place.image_path) ||
''
}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
alt={place.name}
/>
</div>
<div style={{ flex: 1, position: 'relative' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<h3
style={{
fontSize: '16px',
fontWeight: 800,
margin: '0 0 4px',
color: 'var(--color-text-primary)',
}}
>
{index + 1}. {place.name}
</h3>
</div>
<p
style={{
fontSize: '12px',
color: 'var(--color-text-secondary)',
margin: '0 0 4px',
}}
>
{place.category}
</p>
<p
style={{
fontSize: '12px',
color: 'var(--color-text-tertiary)',
margin: '0 0 8px',
}}
>
{place.distance || '0.9 mi'}
</p>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<div style={{ display: 'flex', gap: '1px' }}>
{[1, 2, 3, 4, 5].map((s) => (
<IonIcon
key={s}
icon={star}
style={{
fontSize: '12px',
color:
s <= place.rating
? 'var(--ion-color-primary)'
: 'var(--color-border)',
}}
/>
))}
</div>
<span
style={{
fontSize: '11px',
color: 'var(--color-text-tertiary)',
fontWeight: 600,
}}
>
{place.review_count > 1000
? `${(place.review_count / 1000).toFixed(1)}k`
: place.review_count}{' '}
Reviews
</span>
</div>
{place.busyness === 'busy' && (
<div
style={{
position: 'absolute',
top: 0,
right: 0,
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<span
style={{
fontSize: '10px',
color: 'var(--color-text-tertiary)',
fontWeight: 600,
}}
>
Busy
</span>
<div
style={{
background: 'var(--ion-color-primary)',
color: '#ffffff',
fontSize: '9px',
fontWeight: 800,
padding: '2px 6px',
borderRadius: 'var(--radius-pill)',
}}
>
LIVE
</div>
</div>
)}
</div>
</div>
))
)}
</div>
</IonContent>
</IonPage>
);
};
export default HangoutPage;
+2 -2
View File
@@ -1,11 +1,11 @@
import React from "react";
import React from 'react';
import {
IonContent,
IonHeader,
IonPage,
IonTitle,
IonToolbar,
} from "@ionic/react";
} from '@ionic/react';
const Home: React.FC = () => (
<IonPage>
-699
View File
@@ -1,699 +0,0 @@
import React, { useRef, useState } from 'react';
import {
IonButton,
IonContent,
IonIcon,
IonPage,
useIonViewWillEnter,
} from '@ionic/react';
import {
apps,
close,
heart,
optionsOutline,
paperPlane,
refreshOutline,
walk,
} from 'ionicons/icons';
import Lottie from 'lottie-react';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { getStorageUrl } from '../utils/storage';
import { previewProfiles } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
import heartsAnimation from '../assets/images/hearts.json';
type SwipeAction = 'like' | 'skip';
type DragState = {
x: number;
y: number;
startX: number;
startY: number;
active: boolean;
};
const SWIPE_THRESHOLD = 105;
const EXIT_DISTANCE = 620;
const calculateAge = (birthDate?: string | null, fallback?: number) => {
if (fallback) return fallback;
if (!birthDate) return null;
const birth = new Date(birthDate);
if (Number.isNaN(birth.getTime())) return null;
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age -= 1;
}
return age;
};
const IcebreakerPage: React.FC = () => {
const { user } = useAuth();
const previewMode = isPreviewMode();
const cardRef = useRef<HTMLDivElement | null>(null);
const [profiles, setProfiles] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
const [swiping, setSwiping] = useState(false);
const [showHearts, setShowHearts] = useState(false);
const [drag, setDrag] = useState<DragState | null>(null);
const [exitDirection, setExitDirection] = useState<SwipeAction | null>(null);
const fetchProfiles = async () => {
if (previewMode) {
setProfiles(previewProfiles);
setCurrentIndex(0);
setLoading(false);
return;
}
if (!user) {
setLoading(false);
return;
}
setLoading(true);
try {
const { data: swipedData } = await supabase
.from('swipes')
.select('swiped_id')
.eq('swiper_id', user.id);
const swipedIds = swipedData?.map((s) => s.swiped_id) || [];
const excludedIds = [user.id, ...swipedIds].filter(Boolean);
let query = supabase
.from('profiles')
.select('*, profile_photos(storage_path, position)')
.neq('id', user.id)
.limit(20);
if (excludedIds.length > 0) {
query = query.not('id', 'in', `(${excludedIds.join(',')})`);
}
const { data, error } = await query;
if (error) throw error;
setProfiles(data || []);
setCurrentIndex(0);
} catch (err) {
console.error('Profile fetch error:', err);
setProfiles([]);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchProfiles();
});
const displayProfiles = previewMode ? previewProfiles : profiles;
const currentProfile = displayProfiles.length
? displayProfiles[currentIndex % displayProfiles.length]
: null;
const nextProfile =
displayProfiles.length > 1
? displayProfiles[(currentIndex + 1) % displayProfiles.length]
: null;
const getProfileImages = (profile: any) => {
if (!profile) return [];
if (Array.isArray(profile.images) && profile.images.length > 0) {
return profile.images;
}
if (
Array.isArray(profile.profile_photos) &&
profile.profile_photos.length > 0
) {
return profile.profile_photos
.slice()
.sort((a: any, b: any) => (a.position || 0) - (b.position || 0))
.map((photo: any) =>
getStorageUrl('profile-photos', photo.storage_path)
)
.filter(Boolean);
}
return [
profile.image ||
profile.avatar ||
getStorageUrl('avatars', profile.avatar_path) ||
'',
].filter(Boolean);
};
const getProfileImage = (profile: any) => getProfileImages(profile)[0] || '';
const commitSwipe = async (action: SwipeAction) => {
if (displayProfiles.length === 0) return;
if (previewMode) {
setCurrentIndex((prev) => (prev + 1) % displayProfiles.length);
return;
}
const targetUserId = profiles[currentIndex]?.id;
if (!targetUserId) return;
try {
const { data, error } = await supabase.functions.invoke('handle-swipe', {
body: { targetUserId, action },
});
if (error) throw error;
if (data?.matched && action === 'like') {
setShowHearts(true);
setTimeout(() => setShowHearts(false), 1250);
}
setCurrentIndex((prev) => prev + 1);
} catch (err) {
console.error('Swipe error:', err);
}
};
const animateSwipe = (action: SwipeAction) => {
if (swiping || !currentProfile) return;
setSwiping(true);
setExitDirection(action);
setDrag((prev) => ({
x: action === 'like' ? EXIT_DISTANCE : -EXIT_DISTANCE,
y: prev?.y || -12,
startX: prev?.startX || 0,
startY: prev?.startY || 0,
active: false,
}));
if (action === 'like') {
setShowHearts(true);
setTimeout(() => setShowHearts(false), 1250);
}
window.setTimeout(async () => {
await commitSwipe(action);
setDrag(null);
setExitDirection(null);
setSwiping(false);
}, 280);
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (swiping || !currentProfile) return;
cardRef.current?.setPointerCapture(event.pointerId);
setDrag({
x: 0,
y: 0,
startX: event.clientX,
startY: event.clientY,
active: true,
});
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!drag?.active || swiping) return;
setDrag({
...drag,
x: event.clientX - drag.startX,
y: event.clientY - drag.startY,
});
};
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (!drag || swiping) return;
cardRef.current?.releasePointerCapture(event.pointerId);
const absX = Math.abs(drag.x);
const absY = Math.abs(drag.y);
if (absX < 8 && absY < 8) {
const isRightHalf = event.clientX > window.innerWidth / 2;
setDrag(null);
animateSwipe(isRightHalf ? 'like' : 'skip');
return;
}
if (drag.x > SWIPE_THRESHOLD) {
animateSwipe('like');
return;
}
if (drag.x < -SWIPE_THRESHOLD) {
animateSwipe('skip');
return;
}
setDrag({ ...drag, x: 0, y: 0, active: false });
window.setTimeout(() => setDrag(null), 180);
};
const dragX = drag?.x || 0;
const dragY = drag?.y || 0;
const rotate = Math.max(-12, Math.min(12, dragX / 22));
const transition = drag?.active
? 'none'
: 'transform 280ms cubic-bezier(.2,.9,.22,1), opacity 220ms ease';
const topTransform = `translate3d(${dragX}px, ${dragY}px, 0) rotate(${rotate}deg)`;
const name = currentProfile?.full_name || currentProfile?.name || 'Nearby';
const age = calculateAge(currentProfile?.birth_date, currentProfile?.age);
const distance =
currentProfile?.distance || currentProfile?.travelTime || '3 min';
const interests =
currentProfile?.interests || currentProfile?.userProfile?.interests || [];
const currentImages = getProfileImages(currentProfile);
const currentImage = currentImages[0] || getProfileImage(currentProfile);
const topProgressBars =
currentImages.length > 0 ? currentImages : [currentImage];
return (
<IonPage>
<IonContent
fullscreen
style={{ '--background': '#000000' }}
scrollY={false}
>
<main
style={{
position: 'fixed',
inset: 0,
background: '#000000',
overflow: 'hidden',
touchAction: 'none',
}}
>
{loading && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
fontSize: 15,
}}
>
Finding people nearby
</div>
)}
{!loading && nextProfile && (
<article
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
right: 0,
top: 'calc(var(--ion-safe-area-top, 0px) + 6px)',
bottom: 'calc(76px + var(--ion-safe-area-bottom, 0px))',
borderRadius: 0,
overflow: 'hidden',
background: '#111111',
transform: 'scale(0.975)',
}}
>
<img
src={getProfileImage(nextProfile)}
alt="Next profile"
draggable={false}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
userSelect: 'none',
}}
/>
</article>
)}
{!loading && currentProfile && (
<article
ref={cardRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={{
position: 'absolute',
left: 0,
right: 0,
top: 'calc(var(--ion-safe-area-top, 0px) + 6px)',
bottom: 'calc(76px + var(--ion-safe-area-bottom, 0px))',
borderRadius: 0,
transform: topTransform,
transition,
opacity: exitDirection ? 0.9 : 1,
overflow: 'hidden',
background: '#111111',
cursor: drag?.active ? 'grabbing' : 'grab',
willChange: 'transform, opacity',
}}
>
<img
key={currentProfile.id}
src={currentImage}
alt={name}
draggable={false}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
userSelect: 'none',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
inset: 0,
background:
'linear-gradient(180deg, rgba(0,0,0,0.08) 0%, rgba(0,0,0,0.02) 45%, rgba(0,0,0,0.62) 73%, rgba(0,0,0,0.96) 100%)',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
top: 7,
left: 8,
right: 8,
display: 'grid',
gridTemplateColumns: `repeat(${topProgressBars.length}, 1fr)`,
gap: 4,
zIndex: 4,
}}
>
{topProgressBars.map((_, index) => (
<div
key={`media-progress-${index}`}
style={{
height: 3,
borderRadius: 999,
background:
index === 0
? 'rgba(255,255,255,0.94)'
: 'rgba(30,30,30,0.72)',
}}
/>
))}
</div>
<IonButton
fill="clear"
aria-label="Filter profiles"
style={{
position: 'absolute',
top: 24,
right: 14,
zIndex: 8,
'--color': '#fe3c72',
'--background': 'rgba(28, 28, 28, 0.76)',
'--border-radius': '999px',
width: 48,
height: 48,
}}
>
<IonIcon
icon={optionsOutline}
slot="icon-only"
style={{ fontSize: 27 }}
/>
</IonButton>
<section
style={{
position: 'absolute',
left: 14,
right: 14,
bottom: '34px',
zIndex: 4,
color: '#ffffff',
pointerEvents: 'none',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 14,
marginBottom: 22,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
textShadow: '0 2px 14px rgba(0,0,0,0.75)',
}}
>
<h1
style={{
margin: '0 0 4px',
fontSize: 30,
lineHeight: 1,
fontWeight: 900,
letterSpacing: -0.8,
}}
>
{name}, {age || ''}
</h1>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
color: '#ffffff',
fontSize: 18,
fontWeight: 800,
}}
>
<IonIcon
icon={walk}
style={{
color: '#ffffff',
flexShrink: 0,
fontSize: 25,
}}
/>
<span>{distance}</span>
</div>
</div>
<IonButton
aria-label="Send heart message"
onClick={(event) => {
event.stopPropagation();
animateSwipe('like');
}}
style={{
'--background': '#fe3c72',
'--color': '#111111',
'--border-radius': '999px',
width: 52,
height: 52,
marginTop: 2,
pointerEvents: 'auto',
flexShrink: 0,
}}
>
<IonIcon
icon={paperPlane}
slot="icon-only"
style={{ fontSize: 24 }}
/>
</IonButton>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 10,
fontSize: 21,
fontWeight: 900,
textShadow: '0 2px 12px rgba(0,0,0,0.8)',
}}
>
<IonIcon
icon={apps}
style={{ color: '#ffffff', fontSize: 32, flexShrink: 0 }}
/>
<span>Interests</span>
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
maxWidth: '100%',
}}
>
{interests
.slice(0, 5)
.map((interest: string, index: number) => (
<span
key={interest}
style={{
padding: '7px 13px',
borderRadius: 999,
background:
index === 0 || index === 3
? '#fe3c72'
: 'rgba(44, 53, 57, 0.78)',
color: '#ffffff',
fontSize: 16,
lineHeight: 1,
fontWeight: 650,
backdropFilter: 'blur(12px)',
maxWidth: '100%',
}}
>
{interest}
</span>
))}
</div>
</section>
</article>
)}
{!loading && displayProfiles.length === 0 && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 14,
color: '#ffffff',
padding: 24,
textAlign: 'center',
}}
>
<div style={{ fontSize: 18, fontWeight: 800 }}>
No nearby profiles right now.
</div>
<IonButton
onClick={fetchProfiles}
style={{
'--background': '#fe3c72',
'--border-radius': '999px',
}}
>
<IonIcon icon={refreshOutline} slot="start" />
Refresh
</IonButton>
</div>
)}
{!loading && currentProfile && (
<div
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 'calc(24px + var(--ion-safe-area-bottom, 0px))',
zIndex: 10,
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
padding: '0 54px',
pointerEvents: 'none',
}}
>
<IonButton
aria-label="Skip"
onClick={() => animateSwipe('skip')}
style={{
'--background': 'rgba(45,45,45,0.92)',
'--color': '#ff9f0a',
'--border-radius': '999px',
width: 70,
height: 70,
pointerEvents: 'auto',
}}
>
<IonIcon
icon={close}
slot="icon-only"
style={{ fontSize: 40, fontWeight: 900 }}
/>
</IonButton>
<IonButton
aria-label="Like"
onClick={() => animateSwipe('like')}
style={{
'--background': 'rgba(45,45,45,0.92)',
'--color': '#ff0000',
'--border-radius': '999px',
width: 70,
height: 70,
pointerEvents: 'auto',
}}
>
<IonIcon
icon={heart}
slot="icon-only"
style={{ fontSize: 42 }}
/>
</IonButton>
<IonButton
aria-label="Message"
onClick={() => animateSwipe('like')}
style={{
'--background': 'rgba(45,45,45,0.92)',
'--color': '#1683ff',
'--border-radius': '999px',
width: 70,
height: 70,
pointerEvents: 'auto',
}}
>
<IonIcon
icon={paperPlane}
slot="icon-only"
style={{ fontSize: 38 }}
/>
</IonButton>
</div>
)}
{showHearts && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<Lottie
animationData={heartsAnimation}
loop={false}
style={{ width: 310 }}
/>
</div>
)}
</main>
</IonContent>
</IonPage>
);
};
export default IcebreakerPage;
-165
View File
@@ -1,165 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonInput,
IonItem,
IonButton,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const NewPasswordPage: React.FC = () => {
const history = useHistory();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
showError("Passwords don't match");
return;
}
if (password.length < 6) {
showError('Password must be at least 6 characters');
return;
}
setLoading(true);
try {
const { error } = await supabase.auth.updateUser({ password });
if (error) throw error;
history.push('/icebreaker');
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background': 'var(--color-bg)',
}}
>
<div style={{ padding: 'calc(var(--ion-safe-area-top) + 40px) 20px' }}>
<div style={{ marginBottom: '32px' }}>
<h1
style={{
fontSize: '28px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 8px',
}}
>
Create New Password
</h1>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: 0,
lineHeight: 1.5,
}}
>
Choose a strong password for your InSpot account.
</p>
</div>
<form
onSubmit={handleUpdate}
style={{
background: 'var(--color-surface)',
padding: '24px',
borderRadius: 'var(--radius-card)',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="New Password"
labelPlacement="floating"
type="password"
value={password}
onIonInput={(e) => setPassword(e.detail.value!)}
required
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="Confirm Password"
labelPlacement="floating"
type="password"
value={confirmPassword}
onIonInput={(e) => setConfirmPassword(e.detail.value!)}
required
/>
</IonItem>
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
}}
>
{error}
</div>
)}
<IonButton
type="submit"
expand="block"
disabled={loading}
style={{
'--border-radius': 'var(--radius-pill)',
height: '48px',
marginTop: '8px',
}}
>
{loading ? <IonSpinner name="crescent" /> : 'Update Password'}
</IonButton>
</form>
</div>
</IonContent>
</IonPage>
);
};
export default NewPasswordPage;

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