Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bbc0632f9 |
@@ -4,7 +4,7 @@ android {
|
||||
namespace = "io.ionic.starter"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.inspot.app"
|
||||
applicationId "io.ionic.starter"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
|
||||
@@ -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"
|
||||
@@ -26,19 +21,7 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="com.inspot.app" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="com.inspot.app" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<meta-data
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 742 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 708 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 742 B After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,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">PowerSlap</string>
|
||||
<string name="title_activity_main">PowerSlap</string>
|
||||
<string name="package_name">io.ionic.starter</string>
|
||||
<string name="custom_url_scheme">io.ionic.starter</string>
|
||||
</resources>
|
||||
|
||||
@@ -15,4 +15,5 @@ ext {
|
||||
cordovaAndroidVersion = '14.0.1'
|
||||
rgcfaIncludeGoogle = true
|
||||
androidxCredentialsVersion = '1.3.0'
|
||||
javaVersion = JavaVersion.VERSION_17
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.inspot.app',
|
||||
appName: 'InSpot',
|
||||
appId: 'io.powerslap.app',
|
||||
appName: 'PowerSlap',
|
||||
webDir: 'dist',
|
||||
plugins: {
|
||||
FirebaseAuthentication: {
|
||||
|
||||
@@ -11,9 +11,6 @@ workflows:
|
||||
- $HOME/.gradle/caches
|
||||
- $HOME/.gradle/wrapper
|
||||
scripts:
|
||||
# Template ships with optional native plugins (maps, push, firebase auth) that most apps
|
||||
# won't use. Keeping unused plugins in package.json adds SPM/Gradle deps and bloats the
|
||||
# build. Strip any that have no import in src/ so only actually-used plugins are compiled.
|
||||
- name: Strip unused scaffold packages
|
||||
script: |
|
||||
for pkg in "@capacitor/google-maps" "@capacitor/push-notifications" "@capacitor-firebase/authentication"; do
|
||||
@@ -24,16 +21,10 @@ workflows:
|
||||
echo "Keeping $pkg (used in src/)"
|
||||
fi
|
||||
done
|
||||
# Delete package-lock.json (generated if user ever ran npm locally) so it doesn't
|
||||
# conflict with yarn.lock. --legacy-peer-deps silences peer dep warnings from older
|
||||
# Ionic/React packages that haven't updated their peerDependencies for React 18/19.
|
||||
- name: Install dependencies
|
||||
script: rm -f package-lock.json && npm install --legacy-peer-deps
|
||||
- name: Build web assets
|
||||
script: npm run build
|
||||
# Firebase config files are injected as base64 env vars by the Appcakes backend at
|
||||
# build time. Decode them here so cap sync and Gradle can pick them up. Optional —
|
||||
# builds without Firebase still succeed; native Firebase SDK just won't initialise.
|
||||
- name: Write Firebase config
|
||||
script: |
|
||||
if [ -n "$GOOGLE_SERVICES_JSON" ]; then
|
||||
@@ -46,8 +37,6 @@ workflows:
|
||||
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
|
||||
# cap sync copies the built web assets (dist/) into android/app/src/main/assets/public/
|
||||
# and updates Capacitor plugin registrations in MainActivity. Must run after npm build.
|
||||
- name: Capacitor sync
|
||||
script: npx cap sync android
|
||||
- name: Set up debug keystore
|
||||
@@ -239,25 +228,26 @@ workflows:
|
||||
script: rm -f package-lock.json && npm install --legacy-peer-deps
|
||||
- name: Build web assets
|
||||
script: npm run build
|
||||
# First sync: regenerates CapApp-SPM/Package.swift with the correct plugin list.
|
||||
# Note: this project uses Capacitor SPM (no CocoaPods/Podfile), so cap sync replaces
|
||||
# pod install — it regenerates CapApp-SPM/Package.swift and copies web assets.
|
||||
- name: Capacitor sync
|
||||
script: npx cap sync ios
|
||||
# Decode Firebase plist to disk so it is present during the second sync.
|
||||
- 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
|
||||
# Second sync ensures CapApp-SPM/Package.swift is consistent after the plist write.
|
||||
- name: Capacitor sync
|
||||
script: npx cap sync ios
|
||||
# Run configure_xcode.rb after both syncs so its xcodeproj changes (plist file
|
||||
# reference + SPM product linking) are not overwritten by a subsequent cap sync.
|
||||
# configure_xcode.rb is stamped fresh from template on every build push.
|
||||
- name: Configure Xcode project
|
||||
script: ruby configure_xcode.rb
|
||||
- name: Set up signing
|
||||
script: |
|
||||
keychain initialize
|
||||
@@ -282,23 +272,11 @@ workflows:
|
||||
echo "No signing credentials configured — build will fail at signing"
|
||||
exit 1
|
||||
fi
|
||||
# --workspace: Capacitor SPM projects require workspace context for Xcode to resolve
|
||||
# the CapApp-SPM local package. Using --project causes xcodebuild to fail on
|
||||
# Xcode 26+ because SPM resolution requires workspace scope. App.xcworkspace is a
|
||||
# static file committed to the template (not generated by cap sync) that wraps
|
||||
# App.xcodeproj for this purpose.
|
||||
# --no-show-build-settings: xcode-project runs xcodebuild -showBuildSettings as a
|
||||
# pre-build diagnostic. Xcode 26 fails this step for SPM projects even with
|
||||
# --workspace, so we skip it. The actual archive step still surfaces real errors.
|
||||
# --disable-xcpretty: xcpretty reformats and filters xcodebuild output, swallowing
|
||||
# the actual error lines when a build fails. Raw output makes failures debuggable.
|
||||
- name: Build IPA
|
||||
script: |
|
||||
xcode-project build-ipa \
|
||||
--workspace ios/App/App.xcworkspace \
|
||||
--scheme App \
|
||||
--no-show-build-settings \
|
||||
--disable-xcpretty
|
||||
--project ios/App/App.xcodeproj \
|
||||
--scheme App
|
||||
artifacts:
|
||||
- build/ios/ipa/*.ipa
|
||||
|
||||
@@ -326,15 +304,24 @@ workflows:
|
||||
script: npm run build
|
||||
- name: Capacitor sync
|
||||
script: npx cap sync ios
|
||||
- name: Configure Xcode project
|
||||
- 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: Configure Xcode project
|
||||
script: ruby configure_xcode.rb
|
||||
- name: Set up signing
|
||||
script: |
|
||||
keychain initialize
|
||||
@@ -356,16 +343,12 @@ workflows:
|
||||
echo "$CM_PROVISIONING_PROFILE" | base64 --decode > "$HOME/Library/MobileDevice/Provisioning Profiles/profile.mobileprovision"
|
||||
xcode-project use-profiles
|
||||
fi
|
||||
# See ios-debug Build IPA comments for --workspace, --no-show-build-settings,
|
||||
# and --disable-xcpretty rationale — same reasons apply here.
|
||||
- name: Build IPA
|
||||
script: |
|
||||
xcode-project build-ipa \
|
||||
--workspace ios/App/App.xcworkspace \
|
||||
--project ios/App/App.xcodeproj \
|
||||
--scheme App \
|
||||
--config Release \
|
||||
--no-show-build-settings \
|
||||
--disable-xcpretty
|
||||
--config Release
|
||||
- name: Publish
|
||||
script: |
|
||||
if [ "$SUBMIT_TO_TESTFLIGHT" = "true" ] || [ "$SUBMIT_TO_APP_STORE" = "true" ]; then
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env ruby
|
||||
# configure_xcode.rb — stamped fresh from template on every build (see gitea.service.ts)
|
||||
#
|
||||
# 1. Adds GoogleService-Info.plist to the Xcode target if present (xcodeproj gem)
|
||||
# 2. Scans Swift sources for Firebase imports and injects matching products into
|
||||
# CapApp-SPM/Package.swift — the correct approach for Capacitor SPM projects
|
||||
# where all native linking goes through CapApp-SPM, not App.xcodeproj directly.
|
||||
|
||||
require 'xcodeproj'
|
||||
|
||||
PROJECT_PATH = 'ios/App/App.xcodeproj'
|
||||
TARGET_NAME = 'App'
|
||||
PLIST_SRC = 'ios/App/App/GoogleService-Info.plist'
|
||||
CAP_APP_SPM = 'ios/App/CapApp-SPM/Package.swift'
|
||||
|
||||
# ── 1. GoogleService-Info.plist ───────────────────────────────────────────────
|
||||
if File.exist?(PLIST_SRC)
|
||||
project = Xcodeproj::Project.open(PROJECT_PATH)
|
||||
target = project.targets.find { |t| t.name == TARGET_NAME }
|
||||
if target
|
||||
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
|
||||
puts "Added GoogleService-Info.plist to target"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ── 2. Scan Swift sources for Firebase imports ────────────────────────────────
|
||||
swift_files = Dir['ios/App/App/**/*.swift']
|
||||
firebase_imports = swift_files
|
||||
.flat_map { |f| File.readlines(f) rescue [] }
|
||||
.grep(/^\s*import\s+Firebase\w+/)
|
||||
.map { |l| l.strip.split(/\s+/).last }
|
||||
.uniq
|
||||
.sort
|
||||
|
||||
if firebase_imports.empty?
|
||||
puts "No Firebase imports found — skipping CapApp-SPM update"
|
||||
exit 0
|
||||
end
|
||||
|
||||
puts "Firebase imports detected: #{firebase_imports.join(', ')}"
|
||||
|
||||
unless File.exist?(CAP_APP_SPM)
|
||||
warn "WARNING: #{CAP_APP_SPM} not found — was cap sync run?"
|
||||
exit 0
|
||||
end
|
||||
|
||||
# ── 3. Detect firebase-ios-sdk URL and version from node_modules ──────────────
|
||||
# Read from the ACTUAL node_modules on this build machine (populated by npm install)
|
||||
# so the version we inject matches what @capacitor-firebase/* already resolved.
|
||||
firebase_url = nil
|
||||
firebase_version = nil
|
||||
|
||||
Dir['node_modules/@capacitor-firebase/**/Package.swift',
|
||||
'node_modules/@capacitor/push-notifications/**/Package.swift'].each do |f|
|
||||
content = File.read(f) rescue next
|
||||
url_m = content.match(/\.package\(url:\s*"(https:\/\/github\.com\/firebase\/firebase-ios-sdk[^"]*)"/)
|
||||
ver_m = content.match(/firebase-ios-sdk[^)]+from:\s*"(\d+)\.(\d+)\.(\d+)"/)
|
||||
next unless url_m && ver_m
|
||||
|
||||
firebase_url = url_m[1]
|
||||
major = ver_m[1].to_i
|
||||
firebase_version = "#{major}.0.0"
|
||||
puts "Detected firebase-ios-sdk from #{f}: url=#{firebase_url} → using from: \"#{firebase_version}\""
|
||||
break
|
||||
end
|
||||
|
||||
# Fallback — should rarely be needed
|
||||
unless firebase_url
|
||||
firebase_url = 'https://github.com/firebase/firebase-ios-sdk.git'
|
||||
firebase_version = '11.0.0'
|
||||
puts "Could not detect firebase-ios-sdk version from node_modules — using fallback #{firebase_version}"
|
||||
end
|
||||
|
||||
firebase_pkg = 'firebase-ios-sdk'
|
||||
|
||||
lines = File.read(CAP_APP_SPM).split("\n")
|
||||
|
||||
# ── 4. Inject firebase-ios-sdk as a direct package dependency if missing ──────
|
||||
# Normalise URL check to handle both with and without .git suffix
|
||||
firebase_url_base = firebase_url.sub(/\.git$/, '')
|
||||
unless lines.any? { |l| l.include?(firebase_url_base) }
|
||||
last_pkg_idx = lines.rindex { |l| l.include?('.package(') }
|
||||
if last_pkg_idx
|
||||
lines[last_pkg_idx] = lines[last_pkg_idx].rstrip
|
||||
lines[last_pkg_idx] += ',' unless lines[last_pkg_idx].end_with?(',')
|
||||
lines.insert(last_pkg_idx + 1,
|
||||
" .package(url: \"#{firebase_url}\", from: \"#{firebase_version}\")")
|
||||
puts "Added #{firebase_pkg} to CapApp-SPM package dependencies"
|
||||
else
|
||||
warn "WARNING: no .package( line found in #{CAP_APP_SPM} — cannot inject dependency"
|
||||
end
|
||||
end
|
||||
|
||||
# ── 5. Inject each detected product into the target dependencies if missing ───
|
||||
firebase_imports.each do |product|
|
||||
next if product == 'Firebase' # umbrella import — not a linkable SPM product
|
||||
|
||||
product_str = ".product(name: \"#{product}\", package: \"#{firebase_pkg}\")"
|
||||
next if lines.any? { |l| l.include?(product_str) }
|
||||
|
||||
last_prod_idx = lines.rindex { |l| l.include?('.product(') }
|
||||
if last_prod_idx
|
||||
lines[last_prod_idx] = lines[last_prod_idx].rstrip
|
||||
lines[last_prod_idx] += ',' unless lines[last_prod_idx].end_with?(',')
|
||||
lines.insert(last_prod_idx + 1, " #{product_str}")
|
||||
puts "Added #{product} to CapApp-SPM target dependencies"
|
||||
else
|
||||
warn "WARNING: no .product( line found in #{CAP_APP_SPM} — cannot inject #{product}"
|
||||
end
|
||||
end
|
||||
|
||||
File.write(CAP_APP_SPM, lines.join("\n"))
|
||||
puts "CapApp-SPM/Package.swift updated"
|
||||
@@ -26,7 +26,6 @@
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
1A4EB3C21FED79650016851F /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -69,7 +68,6 @@
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
504EC3131FED79650016851F /* Info.plist */,
|
||||
1A4EB3C21FED79650016851F /* App.entitlements */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
);
|
||||
@@ -297,7 +295,6 @@
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
@@ -308,7 +305,7 @@
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.inspot.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.powerslap.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -320,7 +317,6 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
@@ -330,7 +326,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.inspot.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.powerslap.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:App.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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.
|
||||
|
||||
@@ -1,122 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"scale": "1x",
|
||||
"size": "20x20",
|
||||
"filename": "icon-20.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"scale": "1x",
|
||||
"size": "20x20",
|
||||
"filename": "icon-20.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"scale": "1x",
|
||||
"size": "29x29",
|
||||
"filename": "icon-29.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"scale": "1x",
|
||||
"size": "29x29",
|
||||
"filename": "icon-29.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"scale": "1x",
|
||||
"size": "40x40",
|
||||
"filename": "icon-40.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"scale": "2x",
|
||||
"size": "20x20",
|
||||
"filename": "icon-40.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"scale": "2x",
|
||||
"size": "29x29",
|
||||
"filename": "icon-58.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"scale": "2x",
|
||||
"size": "29x29",
|
||||
"filename": "icon-58.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"scale": "3x",
|
||||
"size": "20x20",
|
||||
"filename": "icon-60.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"scale": "1x",
|
||||
"size": "76x76",
|
||||
"filename": "icon-76.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"scale": "2x",
|
||||
"size": "40x40",
|
||||
"filename": "icon-80.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"scale": "2x",
|
||||
"size": "40x40",
|
||||
"filename": "icon-80.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"scale": "3x",
|
||||
"size": "29x29",
|
||||
"filename": "icon-87.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"scale": "2x",
|
||||
"size": "60x60",
|
||||
"filename": "icon-120.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"scale": "3x",
|
||||
"size": "40x40",
|
||||
"filename": "icon-120.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"scale": "2x",
|
||||
"size": "76x76",
|
||||
"filename": "icon-152.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"scale": "2x",
|
||||
"size": "83.5x83.5",
|
||||
"filename": "icon-167.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"scale": "3x",
|
||||
"size": "60x60",
|
||||
"filename": "icon-180.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ios-marketing",
|
||||
"scale": "1x",
|
||||
"size": "1024x1024",
|
||||
"filename": "icon-1024.png"
|
||||
"filename" : "AppIcon-512@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author": "appsuite",
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 480 B |
|
Before Width: | Height: | Size: 663 B |
|
Before Width: | Height: | Size: 979 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>InSpot</string>
|
||||
<string>PowerSlap</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.powerslap.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>com.inspot.app</string>
|
||||
<string>io.powerslap.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>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-firebase/authentication": "^8.0.0",
|
||||
"@capacitor-firebase/authentication": "^7.0.0",
|
||||
"@capacitor/android": "8.3.4",
|
||||
"@capacitor/app": "8.1.0",
|
||||
"@capacitor/core": "8.3.4",
|
||||
@@ -20,15 +20,13 @@
|
||||
"@capacitor/keyboard": "8.0.3",
|
||||
"@capacitor/push-notifications": "^8.1.1",
|
||||
"@capacitor/status-bar": "8.0.2",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@ionic/pwa-elements": "^3.0.0",
|
||||
"@ionic/react": "8.8.7",
|
||||
"@ionic/react-router": "8.8.7",
|
||||
"@supabase/supabase-js": "^2.108.1",
|
||||
"@ionic/react": "^8.5.0",
|
||||
"@ionic/react-router": "^8.5.0",
|
||||
"@supabase/supabase-js": "^2.0.0",
|
||||
"firebase": "^11.0.0",
|
||||
"ionicons": "^7.4.0",
|
||||
"katex": "^0.16.0",
|
||||
"lottie-react": "^2.4.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.54.0",
|
||||
|
||||
@@ -1,152 +1,31 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route } from 'react-router-dom';
|
||||
import {
|
||||
IonApp,
|
||||
IonIcon,
|
||||
IonLabel,
|
||||
IonRouterOutlet,
|
||||
IonTabBar,
|
||||
IonTabButton,
|
||||
IonTabs,
|
||||
} from '@ionic/react';
|
||||
import { IonReactRouter } from '@ionic/react-router';
|
||||
import {
|
||||
flameOutline,
|
||||
locationOutline,
|
||||
chatbubblesOutline,
|
||||
personOutline,
|
||||
} from 'ionicons/icons';
|
||||
import React from "react";
|
||||
import { Redirect, Route } from "react-router-dom";
|
||||
import { IonApp, IonRouterOutlet } from "@ionic/react";
|
||||
import { IonReactRouter } from "@ionic/react-router";
|
||||
|
||||
/* 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';
|
||||
import "@ionic/react/css/core.css";
|
||||
import "@ionic/react/css/normalize.css";
|
||||
import "@ionic/react/css/structure.css";
|
||||
import "@ionic/react/css/typography.css";
|
||||
import "@ionic/react/css/padding.css";
|
||||
import "@ionic/react/css/float-elements.css";
|
||||
import "@ionic/react/css/text-alignment.css";
|
||||
import "@ionic/react/css/text-transformation.css";
|
||||
import "@ionic/react/css/flex-utils.css";
|
||||
import "@ionic/react/css/display.css";
|
||||
|
||||
/* Optional CSS utils that can be commented out */
|
||||
import '@ionic/react/css/padding.css';
|
||||
import '@ionic/react/css/float-elements.css';
|
||||
import '@ionic/react/css/text-alignment.css';
|
||||
import '@ionic/react/css/text-transformation.css';
|
||||
import '@ionic/react/css/flex-utils.css';
|
||||
import '@ionic/react/css/display.css';
|
||||
import "./theme/variables.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';
|
||||
|
||||
/* Contexts & Components */
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import { isPreviewMode } from './utils/previewMode';
|
||||
import Home from "./pages/Home";
|
||||
|
||||
const App: React.FC = () => (
|
||||
<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 />
|
||||
<Redirect exact from="/" to="/home" />
|
||||
</IonRouterOutlet>
|
||||
</IonReactRouter>
|
||||
</AuthProvider>
|
||||
</IonApp>
|
||||
);
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
||||
{"nm":"Main Scene","ddd":0,"h":1920,"w":1920,"meta":{"g":"@lottiefiles/creator 1.37.1"},"layers":[{"ty":4,"nm":"Layer 1 Outlines 3","sr":1,"st":0,"op":420,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[523.115,1227.748,0],"ix":1},"s":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[43,36],"t":0},{"s":[157,192.00000000000003],"t":25}],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[960,1277.507],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[30],"t":12},{"s":[0],"t":25}],"ix":11}},"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 3","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[-265.484,0],[0,-135.236],[265.484,0],[0,135.235]],"o":[[265.484,0],[0,135.235],[-265.484,0],[0,-135.236]],"v":[[0,-244.865],[480.701,0.001],[0,244.866],[-480.701,0.001]]},"ix":2}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[20],"t":0},{"s":[50],"t":25}],"ix":5},"c":{"a":0,"k":[0.9961,0.2353,0.4471],"ix":3}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[523.115,1227.748],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":1},{"ty":4,"nm":"Layer 1 Outlines 2","sr":1,"st":0,"op":420,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[523.115,382.842,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[960,413.401],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[960,433.401],"t":12},{"s":[960,413.401],"t":25}],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":6,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[106.139,0],[0,106.14],[-106.139,0],[0,-106.141]],"o":[[-106.141,0],[0,-106.141],[106.139,0],[0,106.141]],"v":[[0,44.966],[-192.49,-147.526],[0,-340.016],[192.488,-147.524]]},"ix":2}},{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 2","ix":2,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[210.961,0],[0,-210.961],[-14.577,-16.234],[-13.668,15.223],[0,261.809]],"o":[[-210.963,0],[0,261.809],[13.693,15.25],[14.577,-16.234],[-0.004,-210.961]],"v":[[0,-530.117],[-382.593,-147.526],[-25.633,514.867],[25.633,514.867],[382.593,-147.526]]},"ix":2}},{"ty":"mm","bm":0,"hd":false,"mn":"ADBE Vector Filter - Merge","nm":"Merge Paths 1","mm":1},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.9961,0.2353,0.4471],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[523.115,530.367],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":2},{"ty":4,"nm":"Layer 1 Outlines","sr":1,"st":0,"op":420,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[523.115,1307.748],"ix":1},"s":{"a":0,"k":[100,123],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[960,1278.307],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":80,"ix":11}},"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 2","ix":1,"cix":2,"np":4,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,-46.929],[123.812,0],[0,46.929],[-123.812,0]],"o":[[0,46.929],[-123.812,0],[0,-46.929],[123.812,0]],"v":[[224.182,0],[0,84.972],[-224.182,0],[0,-84.972]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.9961,0.2353,0.4471],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[523.115,1307.748],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":3}],"v":"5.7.0","fr":30,"op":30,"ip":0,"assets":[]}
|
||||
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,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(() => {});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonButton,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
IonRouterLink,
|
||||
} from '@ionic/react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { mail, lockClosed, logoFacebook } from 'ionicons/icons';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
import { enablePreviewMode } from '../utils/previewMode';
|
||||
import { supabase } from '../supabase';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
|
||||
|
||||
const AuthPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
});
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setError(msg);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
const handleAuth = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (mode === 'signin') {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
if (error) {
|
||||
if (error.message.toLowerCase().includes('email not confirmed')) {
|
||||
await supabase.auth.resend({ type: 'signup', email });
|
||||
history.push('/verify-email', { email, type: 'signup' });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
history.push('/icebreaker');
|
||||
} else {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: { onboarding_complete: false },
|
||||
},
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
if (data.session) {
|
||||
history.push('/onboarding/profile');
|
||||
} else {
|
||||
history.push('/verify-email', { email, type: 'signup' });
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const enterPreviewMode = () => {
|
||||
enablePreviewMode();
|
||||
history.push('/icebreaker');
|
||||
};
|
||||
|
||||
const handleSocialSignIn = async (provider: 'google' | 'apple') => {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
showError(
|
||||
`${provider === 'google' ? 'Google' : 'Apple'} Sign-In is only available on native devices.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
let result;
|
||||
if (provider === 'google') {
|
||||
result = await FirebaseAuthentication.signInWithGoogle();
|
||||
} else {
|
||||
result = await FirebaseAuthentication.signInWithApple({
|
||||
skipNativeAuth: true,
|
||||
});
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signInWithIdToken({
|
||||
provider,
|
||||
token: result.credential!.idToken!,
|
||||
nonce: result.credential?.nonce,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
history.push('/icebreaker');
|
||||
} catch (err: any) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent
|
||||
fullscreen
|
||||
className="ion-padding"
|
||||
style={{
|
||||
'--background': 'var(--color-bg)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
paddingTop: 'calc(var(--ion-safe-area-top) + 40px)',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '0 20px' }}>
|
||||
<div style={{ marginBottom: '44px', paddingTop: '24px' }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: 800,
|
||||
color: 'var(--color-text-primary)',
|
||||
margin: '0 0 42px',
|
||||
}}
|
||||
>
|
||||
{mode === 'signin' ? 'Hi there, 👋' : 'Create account'}
|
||||
</h1>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text-primary)',
|
||||
margin: '0 0 12px',
|
||||
}}
|
||||
>
|
||||
{mode === 'signin'
|
||||
? 'Sign in to continue'
|
||||
: 'Sign up to continue'}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
color: 'var(--color-text-secondary)',
|
||||
margin: 0,
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{mode === 'signin'
|
||||
? "Don't have an account? Enter your email and password or choose one of the options below."
|
||||
: 'Enter your email and password to start discovering people and places nearby.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
background: 'var(--color-surface-raised)',
|
||||
padding: '4px',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
{(['signin', 'signup'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
border: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
transition: '0.2s',
|
||||
background:
|
||||
mode === m ? 'var(--ion-color-primary)' : 'transparent',
|
||||
color: mode === m ? '#ffffff' : 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
{m === 'signin' ? 'Sign In' : 'Sign Up'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleAuth}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
padding: '0',
|
||||
borderRadius: '0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<IonItem
|
||||
lines="none"
|
||||
style={{
|
||||
'--background': 'var(--color-input)',
|
||||
'--border-radius': 'var(--radius-control)',
|
||||
'--border-width': '1px',
|
||||
'--border-style': 'solid',
|
||||
'--border-color': 'var(--color-text-secondary)',
|
||||
'--min-height': '48px',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={mail}
|
||||
slot="start"
|
||||
style={{ color: 'var(--ion-color-primary)', fontSize: '20px' }}
|
||||
/>
|
||||
<IonInput
|
||||
label="Email"
|
||||
labelPlacement="floating"
|
||||
type="email"
|
||||
value={email}
|
||||
onIonInput={(e) => setEmail(e.detail.value!)}
|
||||
required
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<IonItem
|
||||
lines="none"
|
||||
style={{
|
||||
'--background': 'var(--color-input)',
|
||||
'--border-radius': 'var(--radius-control)',
|
||||
'--border-width': '1px',
|
||||
'--border-style': 'solid',
|
||||
'--border-color': 'var(--color-text-secondary)',
|
||||
'--min-height': '48px',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={lockClosed}
|
||||
slot="start"
|
||||
style={{ color: 'var(--ion-color-primary)', fontSize: '20px' }}
|
||||
/>
|
||||
<IonInput
|
||||
label="Password"
|
||||
labelPlacement="floating"
|
||||
type="password"
|
||||
value={password}
|
||||
onIonInput={(e) => setPassword(e.detail.value!)}
|
||||
required
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(220, 38, 38, 0.08)',
|
||||
color: 'var(--ion-color-danger)',
|
||||
padding: '12px',
|
||||
borderRadius: 'var(--radius-control)',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IonButton
|
||||
type="submit"
|
||||
expand="block"
|
||||
disabled={loading}
|
||||
style={{
|
||||
'--border-radius': 'var(--radius-pill)',
|
||||
'--color': '#000000',
|
||||
fontWeight: 800,
|
||||
height: '48px',
|
||||
margin: '8px 28px 0',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<IonSpinner name="crescent" />
|
||||
) : mode === 'signin' ? (
|
||||
'Sign in'
|
||||
) : (
|
||||
'Continue'
|
||||
)}
|
||||
</IonButton>
|
||||
|
||||
{mode === 'signin' && (
|
||||
<IonRouterLink
|
||||
routerLink="/forgot-password"
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: '13px',
|
||||
color: 'var(--ion-color-primary)',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
Forgot password?
|
||||
</IonRouterLink>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
margin: '56px 0 26px',
|
||||
fontSize: '13px',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
}}
|
||||
>
|
||||
OR
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
padding: '0 28px',
|
||||
}}
|
||||
>
|
||||
<IonButton
|
||||
expand="block"
|
||||
onClick={() => handleSocialSignIn('apple')}
|
||||
style={{
|
||||
flex: 1,
|
||||
'--background': '#ffffff',
|
||||
'--color': '#000000',
|
||||
'--border-radius': 'var(--radius-pill)',
|
||||
'--box-shadow': 'none',
|
||||
height: '48px',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
style={{ marginRight: '10px' }}
|
||||
>
|
||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||
</svg>
|
||||
Sign in with Apple
|
||||
</IonButton>
|
||||
<IonButton
|
||||
expand="block"
|
||||
onClick={() =>
|
||||
showError(
|
||||
'Facebook Sign-In is only available on native devices.'
|
||||
)
|
||||
}
|
||||
style={{
|
||||
flex: 1,
|
||||
'--background': '#ffffff',
|
||||
'--color': '#000000',
|
||||
'--border-radius': 'var(--radius-pill)',
|
||||
'--box-shadow': 'none',
|
||||
height: '48px',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={logoFacebook}
|
||||
style={{ marginRight: '10px', fontSize: '22px' }}
|
||||
/>
|
||||
Sign in with Facebook
|
||||
</IonButton>
|
||||
<IonButton
|
||||
expand="block"
|
||||
onClick={() => handleSocialSignIn('google')}
|
||||
style={{
|
||||
flex: 1,
|
||||
'--background': '#ffffff',
|
||||
'--color': '#000000',
|
||||
'--border-radius': 'var(--radius-pill)',
|
||||
'--box-shadow': 'none',
|
||||
height: '48px',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 900,
|
||||
marginRight: '12px',
|
||||
}}
|
||||
>
|
||||
G
|
||||
</span>
|
||||
Sign in with Google
|
||||
</IonButton>
|
||||
<IonButton
|
||||
expand="block"
|
||||
onClick={enterPreviewMode}
|
||||
style={{
|
||||
flex: 1,
|
||||
'--background': 'var(--ion-color-primary)',
|
||||
'--color': '#000000',
|
||||
'--border-radius': 'var(--radius-pill)',
|
||||
'--box-shadow': 'none',
|
||||
height: '48px',
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
Preview with dummy data
|
||||
</IonButton>
|
||||
</div>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthPage;
|
||||
@@ -1,311 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonIcon,
|
||||
IonButton,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
IonModal,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonButtons,
|
||||
IonItem,
|
||||
IonTextarea,
|
||||
IonSelect,
|
||||
IonSelectOption,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
closeOutline,
|
||||
cameraOutline,
|
||||
imageOutline,
|
||||
sendOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
|
||||
const CameraPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user } = useAuth();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [caption, setCaption] = useState('');
|
||||
const [placeId, setPlaceId] = useState<string | null>(null);
|
||||
const [places, setPlaces] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [isPublishOpen, setIsPublishOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
fetchPlaces();
|
||||
});
|
||||
|
||||
const fetchPlaces = async () => {
|
||||
const { data } = await supabase.from('places').select('id, name');
|
||||
setPlaces(data || []);
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
setPreviewUrl(URL.createObjectURL(selectedFile));
|
||||
setIsPublishOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!file || !user || !placeId) {
|
||||
setError('Please select a place and add a file.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
|
||||
const filePath = `${fileName}`;
|
||||
|
||||
// 1. Upload to storage
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('reels')
|
||||
.upload(filePath, file);
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
// 2. Create reel record
|
||||
const { error: reelError } = await supabase.from('reels').insert({
|
||||
author_id: user.id,
|
||||
place_id: placeId,
|
||||
caption,
|
||||
media_path: filePath,
|
||||
media_type: file.type.startsWith('video') ? 'video' : 'image',
|
||||
});
|
||||
|
||||
if (reelError) throw reelError;
|
||||
|
||||
history.replace(`/reels/${placeId}`);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent fullscreen style={{ '--background': '#000000' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(14px + var(--ion-safe-area-top))',
|
||||
left: '20px',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<IonButton
|
||||
fill="clear"
|
||||
onClick={() => history.goBack()}
|
||||
style={{
|
||||
'--background': 'rgba(0,0,0,0.3)',
|
||||
'--color': '#ffffff',
|
||||
'--border-radius': '50%',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon icon={closeOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '120px',
|
||||
height: '120px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={cameraOutline}
|
||||
style={{ fontSize: '48px', opacity: 0.5 }}
|
||||
/>
|
||||
</div>
|
||||
<h2
|
||||
style={{ fontSize: '20px', fontWeight: 800, marginBottom: '8px' }}
|
||||
>
|
||||
Create a Reel
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
opacity: 0.6,
|
||||
fontSize: '14px',
|
||||
marginBottom: '32px',
|
||||
textAlign: 'center',
|
||||
padding: '0 40px',
|
||||
}}
|
||||
>
|
||||
Share your experience at a local spot. Select a photo or video to
|
||||
begin.
|
||||
</p>
|
||||
|
||||
<label
|
||||
style={{
|
||||
background: 'var(--ion-color-primary)',
|
||||
color: '#ffffff',
|
||||
padding: '14px 32px',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
fontWeight: 700,
|
||||
fontSize: '15px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<IonIcon icon={imageOutline} style={{ fontSize: '20px' }} />
|
||||
Select Media
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
onChange={handleFileChange}
|
||||
hidden
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Publish Modal */}
|
||||
<IonModal
|
||||
isOpen={isPublishOpen}
|
||||
onDidDismiss={() => {
|
||||
setIsPublishOpen(false);
|
||||
setPreviewUrl(null);
|
||||
setFile(null);
|
||||
}}
|
||||
>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>New Reel</IonTitle>
|
||||
<IonButtons slot="start">
|
||||
<IonButton onClick={() => setIsPublishOpen(false)}>
|
||||
Cancel
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonButtons slot="end">
|
||||
<IonButton
|
||||
onClick={handlePublish}
|
||||
disabled={uploading || !placeId}
|
||||
>
|
||||
{uploading ? <IonSpinner name="crescent" /> : 'Share'}
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent className="ion-padding">
|
||||
<div style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '100px',
|
||||
height: '140px',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
background: '#000',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{file?.type.startsWith('video') ? (
|
||||
<video
|
||||
src={previewUrl || ''}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={previewUrl || ''}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
alt="Preview"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<IonTextarea
|
||||
placeholder="Write a caption..."
|
||||
value={caption}
|
||||
onIonInput={(e) => setCaption(e.detail.value!)}
|
||||
rows={6}
|
||||
style={{
|
||||
'--background': 'var(--color-bg)',
|
||||
'--padding-start': '12px',
|
||||
'--border-radius': '12px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<IonItem
|
||||
lines="none"
|
||||
style={{
|
||||
'--background': 'var(--color-bg)',
|
||||
'--border-radius': 'var(--radius-control)',
|
||||
}}
|
||||
>
|
||||
<IonSelect
|
||||
label="Tag a place"
|
||||
labelPlacement="floating"
|
||||
placeholder="Select place"
|
||||
value={placeId}
|
||||
onIonChange={(e) => setPlaceId(e.detail.value)}
|
||||
>
|
||||
{places.map((p) => (
|
||||
<IonSelectOption key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</IonSelectOption>
|
||||
))}
|
||||
</IonSelect>
|
||||
</IonItem>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--ion-color-danger)',
|
||||
fontSize: '13px',
|
||||
marginTop: '12px',
|
||||
padding: '0 4px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonModal>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraPage;
|
||||
@@ -1,263 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonButtons,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonInput,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { chevronBackOutline, sendOutline } from 'ionicons/icons';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
import Avatar from '../components/Avatar';
|
||||
import { previewChats, previewMessages } from '../utils/previewData';
|
||||
import { isPreviewMode } from '../utils/previewMode';
|
||||
|
||||
const ChatThreadPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { conversationId } = useParams<{ conversationId: string }>();
|
||||
const { user } = useAuth();
|
||||
const previewMode = isPreviewMode();
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [otherUser, setOtherUser] = useState<any>(null);
|
||||
const [text, setText] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const scrollRef = useRef<HTMLIonContentElement>(null);
|
||||
|
||||
const fetchThread = async () => {
|
||||
setLoading(true);
|
||||
if (previewMode) {
|
||||
const chat =
|
||||
previewChats.find((item) => item.id === conversationId) ||
|
||||
previewChats[0];
|
||||
setOtherUser(chat.otherUser);
|
||||
setMessages(previewMessages[chat.id] || []);
|
||||
setLoading(false);
|
||||
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
|
||||
return;
|
||||
}
|
||||
if (!user) return;
|
||||
try {
|
||||
// 1. Get other participant
|
||||
const { data: conv } = await supabase
|
||||
.from('conversations')
|
||||
.select(
|
||||
`
|
||||
match_id,
|
||||
matches(
|
||||
user_a:profiles!matches_user_a_fkey(id, full_name, avatar_path),
|
||||
user_b:profiles!matches_user_b_fkey(id, full_name, avatar_path)
|
||||
)
|
||||
`
|
||||
)
|
||||
.eq('id', conversationId)
|
||||
.single();
|
||||
|
||||
if (conv) {
|
||||
const match = (conv as any).matches;
|
||||
setOtherUser(match.user_a.id === user.id ? match.user_b : match.user_a);
|
||||
}
|
||||
|
||||
// 2. Get messages
|
||||
const { data: msgs } = await supabase
|
||||
.from('messages')
|
||||
.select('*')
|
||||
.eq('conversation_id', conversationId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
setMessages(msgs || []);
|
||||
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
|
||||
} catch (err: any) {
|
||||
console.error('Fetch thread error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
fetchThread();
|
||||
});
|
||||
|
||||
const handleSend = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!text.trim() || (!user && !previewMode) || sending) return;
|
||||
|
||||
setSending(true);
|
||||
const msgBody = text.trim();
|
||||
setText('');
|
||||
|
||||
if (previewMode) {
|
||||
const previewMessage = {
|
||||
id: `preview-message-${Date.now()}`,
|
||||
sender_id: 'preview-me',
|
||||
body: msgBody,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, previewMessage]);
|
||||
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('messages')
|
||||
.insert({
|
||||
conversation_id: conversationId,
|
||||
sender_id: user.id,
|
||||
body: msgBody,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
setMessages((prev) => [...prev, data]);
|
||||
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
|
||||
|
||||
// Invoke notification
|
||||
supabase.functions.invoke('send-message-notification', {
|
||||
body: { conversationId, messageId: data.id },
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('Send error:', err);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
|
||||
<IonButtons slot="start">
|
||||
<IonButton
|
||||
onClick={() => history.goBack()}
|
||||
style={{ '--color': 'var(--color-text-primary)' }}
|
||||
>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle>
|
||||
{otherUser && (
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||
>
|
||||
<Avatar
|
||||
src={otherUser.avatar_path}
|
||||
name={otherUser.full_name}
|
||||
size={32}
|
||||
/>
|
||||
<span style={{ fontSize: '16px', fontWeight: 700 }}>
|
||||
{otherUser.full_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent
|
||||
ref={scrollRef}
|
||||
style={{ '--background': 'var(--color-bg)' }}
|
||||
className="ion-padding"
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<IonSpinner name="crescent" color="primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
paddingBottom: '20px',
|
||||
}}
|
||||
>
|
||||
{messages.map((msg) => {
|
||||
const isMine = previewMode
|
||||
? msg.sender_id === 'preview-me'
|
||||
: msg.sender_id === user?.id;
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
style={{
|
||||
alignSelf: isMine ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '78%',
|
||||
background: isMine
|
||||
? 'var(--ion-color-primary)'
|
||||
: 'var(--color-surface)',
|
||||
color: isMine ? '#ffffff' : 'var(--color-text-primary)',
|
||||
padding: '10px 14px',
|
||||
borderRadius: isMine
|
||||
? '18px 18px 4px 18px'
|
||||
: '18px 18px 18px 4px',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.4,
|
||||
boxShadow: '0 2px 8px rgba(76, 5, 25, 0.02)',
|
||||
}}
|
||||
>
|
||||
{msg.body}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</IonContent>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
padding: '10px 20px calc(10px + var(--ion-safe-area-bottom))',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSend}
|
||||
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
|
||||
>
|
||||
<IonInput
|
||||
value={text}
|
||||
onIonInput={(e) => setText(e.detail.value!)}
|
||||
placeholder="Type a message..."
|
||||
style={{
|
||||
background: 'var(--color-bg)',
|
||||
'--border-radius': 'var(--radius-pill)',
|
||||
'--padding-start': '16px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
<IonButton
|
||||
type="submit"
|
||||
disabled={!text.trim() || sending}
|
||||
style={{
|
||||
'--border-radius': '50%',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
margin: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{sending ? (
|
||||
<IonSpinner name="crescent" />
|
||||
) : (
|
||||
<IonIcon icon={sendOutline} slot="icon-only" />
|
||||
)}
|
||||
</IonButton>
|
||||
</form>
|
||||
</div>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatThreadPage;
|
||||
@@ -1,218 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
IonRefresher,
|
||||
IonRefresherContent,
|
||||
IonIcon,
|
||||
} from '@ionic/react';
|
||||
import { chatbubbleEllipsesOutline } from 'ionicons/icons';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
import { formatTime } from '../utils/format';
|
||||
import SearchRow from '../components/SearchRow';
|
||||
import Avatar from '../components/Avatar';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import { previewChats } from '../utils/previewData';
|
||||
import { isPreviewMode } from '../utils/previewMode';
|
||||
|
||||
const ChatsPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user } = useAuth();
|
||||
const previewMode = isPreviewMode();
|
||||
const [chats, setChats] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const fetchChats = async () => {
|
||||
if (previewMode) {
|
||||
setChats(previewChats);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('matches')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
last_message_at,
|
||||
user_a:profiles!matches_user_a_fkey(id, full_name, avatar_path),
|
||||
user_b:profiles!matches_user_b_fkey(id, full_name, avatar_path),
|
||||
conversations(
|
||||
id,
|
||||
messages(
|
||||
body,
|
||||
created_at
|
||||
)
|
||||
)
|
||||
`
|
||||
)
|
||||
.or(`user_a.eq.${user.id},user_b.eq.${user.id}`)
|
||||
.order('last_message_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const formatted =
|
||||
data?.map((m) => {
|
||||
const otherUser = m.user_a.id === user.id ? m.user_b : m.user_a;
|
||||
const conversation = m.conversations?.[0];
|
||||
const lastMsg = conversation?.messages?.[0]; // ordered by handle-swipe or trigger later
|
||||
return {
|
||||
id: conversation?.id,
|
||||
otherUser,
|
||||
lastMessage: lastMsg?.body || 'New match! Start chatting.',
|
||||
timestamp: m.last_message_at || lastMsg?.created_at,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
setChats(formatted);
|
||||
} catch (err: any) {
|
||||
console.error('Fetch chats error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
fetchChats();
|
||||
});
|
||||
|
||||
const handleRefresh = async (event: CustomEvent) => {
|
||||
await fetchChats();
|
||||
event.detail.complete();
|
||||
};
|
||||
|
||||
const displayChats = previewMode ? previewChats : chats;
|
||||
const filteredChats = displayChats.filter(
|
||||
(c) =>
|
||||
c.otherUser.full_name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
c.lastMessage.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
|
||||
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
|
||||
<IonRefresherContent />
|
||||
</IonRefresher>
|
||||
|
||||
<div
|
||||
style={{ padding: 'calc(16px + var(--ion-safe-area-top)) 20px 12px' }}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 800,
|
||||
color: 'var(--color-text-primary)',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Chats
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<SearchRow
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.detail.value!)}
|
||||
placeholder="Search conversations"
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{ paddingBottom: 'calc(92px + var(--ion-safe-area-bottom))' }}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<IonSpinner name="crescent" color="primary" />
|
||||
</div>
|
||||
) : filteredChats.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={chatbubbleEllipsesOutline}
|
||||
title="No chats yet"
|
||||
message="Matches you message will appear here. Start swiping to find someone!"
|
||||
ctaText="Find people"
|
||||
onCtaClick={() => history.push('/icebreaker')}
|
||||
/>
|
||||
) : (
|
||||
filteredChats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
onClick={() => history.push(`/chat/${chat.id}`)}
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
margin: '0 20px 12px',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
src={chat.otherUser.image || chat.otherUser.avatar_path}
|
||||
name={chat.otherUser.full_name}
|
||||
size={56}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 800,
|
||||
margin: 0,
|
||||
color: 'var(--color-text-primary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{chat.otherUser.full_name}
|
||||
</h3>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{formatTime(chat.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'var(--color-text-secondary)',
|
||||
margin: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{chat.lastMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatsPage;
|
||||
@@ -1,378 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonButtons,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonInput,
|
||||
IonTextarea,
|
||||
IonSelect,
|
||||
IonSelectOption,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
chevronBackOutline,
|
||||
cameraOutline,
|
||||
trashOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
import { getStorageUrl } from '../utils/storage';
|
||||
|
||||
const EditProfilePage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user, profile, refreshProfile } = useAuth();
|
||||
const [fullName, setFullName] = useState(profile?.full_name || '');
|
||||
const [bio, setBio] = useState(profile?.bio || '');
|
||||
const [gender, setGender] = useState(profile?.gender || '');
|
||||
const [relationshipGoal, setRelationshipGoal] = useState(
|
||||
profile?.relationship_goal || ''
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [photos, setPhotos] = useState<any[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const fetchPhotos = async () => {
|
||||
if (!user) return;
|
||||
const { data } = await supabase
|
||||
.from('profile_photos')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('position');
|
||||
setPhotos(data || []);
|
||||
};
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
if (profile) {
|
||||
setFullName(profile.full_name);
|
||||
setBio(profile.bio || '');
|
||||
setGender(profile.gender || '');
|
||||
setRelationshipGoal(profile.relationship_goal || '');
|
||||
}
|
||||
fetchPhotos();
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
full_name: fullName,
|
||||
bio,
|
||||
gender,
|
||||
relationship_goal: relationshipGoal,
|
||||
})
|
||||
.eq('id', user.id);
|
||||
|
||||
if (error) throw error;
|
||||
await refreshProfile();
|
||||
history.goBack();
|
||||
} catch (err: any) {
|
||||
console.error('Save error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !user) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
|
||||
const filePath = `profile-photos/${fileName}`;
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('profile-photos')
|
||||
.upload(filePath, file);
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
const { error: dbError } = await supabase.from('profile_photos').insert({
|
||||
user_id: user.id,
|
||||
storage_path: filePath,
|
||||
position: photos.length,
|
||||
});
|
||||
|
||||
if (dbError) throw dbError;
|
||||
fetchPhotos();
|
||||
} catch (err: any) {
|
||||
console.error('Photo upload error:', err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePhoto = async (id: string, path: string) => {
|
||||
try {
|
||||
await supabase.from('profile_photos').delete().eq('id', id);
|
||||
await supabase.storage.from('profile-photos').remove([path]);
|
||||
fetchPhotos();
|
||||
} catch (err) {
|
||||
console.error('Delete photo error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const goals = [
|
||||
{ label: 'Long-term partner', value: 'long_term' },
|
||||
{ label: 'Long-term, but short-term OK', value: 'long_term_short_ok' },
|
||||
{ label: 'Short-term, but long-term OK', value: 'short_term_long_ok' },
|
||||
{ label: 'Short-term fun', value: 'short_term' },
|
||||
{ label: 'New friends', value: 'friends' },
|
||||
{ label: 'Still figuring it out', value: 'figuring_it_out' },
|
||||
];
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
|
||||
<IonButtons slot="start">
|
||||
<IonButton
|
||||
onClick={() => history.goBack()}
|
||||
style={{ '--color': 'var(--color-text-primary)' }}
|
||||
>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle>Edit Profile</IonTitle>
|
||||
<IonButtons slot="end">
|
||||
<IonButton
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
style={{ fontWeight: 700 }}
|
||||
>
|
||||
{loading ? <IonSpinner name="crescent" /> : 'Save'}
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent
|
||||
className="ion-padding"
|
||||
style={{ '--background': 'var(--color-bg)' }}
|
||||
>
|
||||
{/* Photo Manager */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 800,
|
||||
color: 'var(--color-text-tertiary)',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
Profile Photos
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
{photos.map((photo) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
style={{
|
||||
paddingBottom: '125%',
|
||||
background: 'var(--color-surface-raised)',
|
||||
borderRadius: '12px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
getStorageUrl('profile-photos', photo.storage_path) || ''
|
||||
}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
alt="Profile"
|
||||
/>
|
||||
<button
|
||||
onClick={() => deletePhoto(photo.id, photo.storage_path)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '8px',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IonIcon icon={trashOutline} style={{ fontSize: '14px' }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{photos.length < 6 && (
|
||||
<label
|
||||
style={{
|
||||
paddingBottom: '125%',
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: '12px',
|
||||
position: 'relative',
|
||||
border: '2px dashed var(--ion-color-primary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<IonSpinner name="crescent" />
|
||||
) : (
|
||||
<IonIcon
|
||||
icon={cameraOutline}
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
color: 'var(--ion-color-primary)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handlePhotoUpload}
|
||||
hidden
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
}}
|
||||
>
|
||||
<IonItem
|
||||
lines="none"
|
||||
style={{
|
||||
'--background': 'var(--color-bg)',
|
||||
'--border-radius': 'var(--radius-control)',
|
||||
}}
|
||||
>
|
||||
<IonInput
|
||||
label="Full Name"
|
||||
labelPlacement="floating"
|
||||
value={fullName}
|
||||
onIonInput={(e) => setFullName(e.detail.value!)}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<IonItem
|
||||
lines="none"
|
||||
style={{
|
||||
'--background': 'var(--color-bg)',
|
||||
'--border-radius': 'var(--radius-control)',
|
||||
}}
|
||||
>
|
||||
<IonSelect
|
||||
label="Gender"
|
||||
labelPlacement="floating"
|
||||
value={gender}
|
||||
onIonChange={(e) => setGender(e.detail.value)}
|
||||
interface="action-sheet"
|
||||
>
|
||||
<IonSelectOption value="woman">Woman</IonSelectOption>
|
||||
<IonSelectOption value="man">Man</IonSelectOption>
|
||||
<IonSelectOption value="nonbinary">Non-binary</IonSelectOption>
|
||||
<IonSelectOption value="other">Other</IonSelectOption>
|
||||
<IonSelectOption value="prefer_not_to_say">
|
||||
Prefer not to say
|
||||
</IonSelectOption>
|
||||
</IonSelect>
|
||||
</IonItem>
|
||||
|
||||
<IonItem
|
||||
lines="none"
|
||||
style={{
|
||||
'--background': 'var(--color-bg)',
|
||||
'--border-radius': 'var(--radius-control)',
|
||||
}}
|
||||
>
|
||||
<IonSelect
|
||||
label="Relationship Goal"
|
||||
labelPlacement="floating"
|
||||
value={relationshipGoal}
|
||||
onIonChange={(e) => setRelationshipGoal(e.detail.value)}
|
||||
interface="action-sheet"
|
||||
>
|
||||
{goals.map((g) => (
|
||||
<IonSelectOption key={g.value} value={g.value}>
|
||||
{g.label}
|
||||
</IonSelectOption>
|
||||
))}
|
||||
</IonSelect>
|
||||
</IonItem>
|
||||
|
||||
<IonItem
|
||||
lines="none"
|
||||
style={{
|
||||
'--background': 'var(--color-bg)',
|
||||
'--border-radius': 'var(--radius-control)',
|
||||
}}
|
||||
>
|
||||
<IonTextarea
|
||||
label="Bio"
|
||||
labelPlacement="floating"
|
||||
value={bio}
|
||||
onIonInput={(e) => setBio(e.detail.value!)}
|
||||
placeholder="Tell us about yourself..."
|
||||
rows={4}
|
||||
/>
|
||||
</IonItem>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditProfilePage;
|
||||
@@ -1,153 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonButton,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
|
||||
const ForgotPasswordPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
});
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setError(msg);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
const handleReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email);
|
||||
if (error) throw error;
|
||||
history.push('/verify-email', { email, type: 'recovery' });
|
||||
} catch (err: any) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent
|
||||
fullscreen
|
||||
style={{
|
||||
'--background': 'var(--color-bg)',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: 'calc(var(--ion-safe-area-top) + 40px) 20px' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: 800,
|
||||
color: 'var(--color-text-primary)',
|
||||
margin: '0 0 8px',
|
||||
}}
|
||||
>
|
||||
Reset Password
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
color: 'var(--color-text-secondary)',
|
||||
margin: 0,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Enter your email and we'll send you a code to reset your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleReset}
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
padding: '24px',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<IonItem
|
||||
lines="none"
|
||||
style={{
|
||||
'--background': 'var(--color-bg)',
|
||||
'--border-radius': 'var(--radius-control)',
|
||||
}}
|
||||
>
|
||||
<IonInput
|
||||
label="Email"
|
||||
labelPlacement="floating"
|
||||
type="email"
|
||||
value={email}
|
||||
onIonInput={(e) => setEmail(e.detail.value!)}
|
||||
required
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(220, 38, 38, 0.08)',
|
||||
color: 'var(--ion-color-danger)',
|
||||
padding: '12px',
|
||||
borderRadius: 'var(--radius-control)',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IonButton
|
||||
type="submit"
|
||||
expand="block"
|
||||
disabled={loading}
|
||||
style={{
|
||||
'--border-radius': 'var(--radius-pill)',
|
||||
height: '48px',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
{loading ? <IonSpinner name="crescent" /> : 'Send Code'}
|
||||
</IonButton>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => history.goBack()}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
marginTop: '12px',
|
||||
}}
|
||||
>
|
||||
Back to Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPasswordPage;
|
||||
@@ -1,558 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonIcon,
|
||||
IonButton,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
IonRefresher,
|
||||
IonRefresherContent,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
locationOutline,
|
||||
notificationsOutline,
|
||||
star,
|
||||
cameraOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
import { formatDistance } from '../utils/format';
|
||||
import { getStorageUrl } from '../utils/storage';
|
||||
import SearchRow from '../components/SearchRow';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import { previewPlaces } from '../utils/previewData';
|
||||
import { isPreviewMode } from '../utils/previewMode';
|
||||
|
||||
const HangoutPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user } = useAuth();
|
||||
const previewMode = isPreviewMode();
|
||||
const [places, setPlaces] = useState<any[]>([]);
|
||||
const [reelStories, setReelStories] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [query, setQuery] = useState('');
|
||||
const [sort, setSort] = useState<'most_busy' | 'nearest' | 'top_rated'>(
|
||||
'most_busy'
|
||||
);
|
||||
|
||||
const fetchPlaces = async () => {
|
||||
setLoading(true);
|
||||
if (previewMode) {
|
||||
setPlaces(previewPlaces);
|
||||
setReelStories(previewPlaces);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Phase 1: simple local query. Phase 2: use places-nearby Edge Function
|
||||
const { data, error } = await supabase
|
||||
.from('places')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
reels(id)
|
||||
`
|
||||
)
|
||||
.order('rating', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setPlaces(data || []);
|
||||
|
||||
// Derive stories (places with reels)
|
||||
const stories =
|
||||
data?.filter((p) => p.reels?.length > 0).slice(0, 10) || [];
|
||||
setReelStories(stories);
|
||||
} catch (err: any) {
|
||||
console.error('Fetch places error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
fetchPlaces();
|
||||
});
|
||||
|
||||
const handleRefresh = async (event: CustomEvent) => {
|
||||
await fetchPlaces();
|
||||
event.detail.complete();
|
||||
};
|
||||
|
||||
const displayPlaces = previewMode ? previewPlaces : places;
|
||||
const displayStories = previewMode ? previewPlaces : reelStories;
|
||||
const filteredPlaces = displayPlaces.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
p.category.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
|
||||
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
|
||||
<IonRefresherContent />
|
||||
</IonRefresher>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: 'calc(14px + var(--ion-safe-area-top)) 20px 8px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={locationOutline}
|
||||
style={{ color: 'var(--ion-color-primary)', fontSize: '18px' }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-tertiary)',
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Nearby now
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '17px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
Midrand, ZA
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<IonButton
|
||||
fill="clear"
|
||||
onClick={() => history.push('/notifications')}
|
||||
style={{
|
||||
'--background': 'rgba(255,255,255,0.06)',
|
||||
'--color': 'var(--color-text-primary)',
|
||||
'--border-radius': '12px',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
margin: 0,
|
||||
'--padding-start': 0,
|
||||
'--padding-end': 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={notificationsOutline}
|
||||
slot="icon-only"
|
||||
style={{ fontSize: '20px' }}
|
||||
/>
|
||||
</IonButton>
|
||||
</div>
|
||||
|
||||
<SearchRow
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.detail.value!)}
|
||||
placeholder="Search place of interest"
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
padding: '2px 20px 26px',
|
||||
overflowX: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{(['most_busy', 'nearest', 'top_rated'] as const).map((s) => {
|
||||
const active = sort === s;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSort(s)}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
border: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: active ? 700 : 600,
|
||||
whiteSpace: 'nowrap',
|
||||
background: active
|
||||
? 'linear-gradient(180deg, #f24f88 0%, var(--ion-color-primary) 100%)'
|
||||
: 'rgba(255,255,255,0.05)',
|
||||
color: active ? '#ffffff' : 'rgba(255,255,255,0.82)',
|
||||
boxShadow: active
|
||||
? '0 10px 24px rgba(254, 60, 114, 0.26)'
|
||||
: 'none',
|
||||
transition: '0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{s.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reels Stories Section */}
|
||||
<div style={{ marginBottom: '26px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0 20px 14px',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 800,
|
||||
margin: 0,
|
||||
color: 'var(--color-text-primary)',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
Reels
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => history.push('/camera')}
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.06) 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={cameraOutline}
|
||||
style={{ fontSize: '20px', color: 'var(--color-text-primary)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '14px',
|
||||
padding: '0 20px',
|
||||
overflowX: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
<div style={{ flexShrink: 0, width: '76px', textAlign: 'center' }}>
|
||||
<div
|
||||
onClick={() => history.push('/camera')}
|
||||
style={{
|
||||
width: '68px',
|
||||
height: '68px',
|
||||
borderRadius: '50%',
|
||||
padding: '2px',
|
||||
background:
|
||||
'linear-gradient(180deg, #f24f88 0%, #a60b4a 100%)',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '50%',
|
||||
background: '#08080a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={cameraOutline}
|
||||
style={{
|
||||
fontSize: '22px',
|
||||
color: 'var(--ion-color-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
opacity: 0.95,
|
||||
}}
|
||||
>
|
||||
My reels
|
||||
</span>
|
||||
</div>
|
||||
{displayStories.map((story) => (
|
||||
<div
|
||||
key={story.id}
|
||||
onClick={() => history.push(`/reels/${story.id}`)}
|
||||
style={{ flexShrink: 0, width: '76px', textAlign: 'center' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '68px',
|
||||
height: '68px',
|
||||
borderRadius: '50%',
|
||||
padding: '2px',
|
||||
background:
|
||||
'linear-gradient(180deg, #f24f88 0%, #a60b4a 100%)',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '50%',
|
||||
padding: '3px',
|
||||
background: '#08080a',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
story.image ||
|
||||
getStorageUrl('place-media', story.image_path) ||
|
||||
''
|
||||
}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
alt={story.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: 0.95,
|
||||
}}
|
||||
>
|
||||
{story.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Places List */}
|
||||
<div
|
||||
style={{ paddingBottom: 'calc(92px + var(--ion-safe-area-bottom))' }}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 800,
|
||||
margin: '0 20px 16px',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
Places of interest 📍
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<IonSpinner name="crescent" color="primary" />
|
||||
</div>
|
||||
) : filteredPlaces.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={locationOutline}
|
||||
title="No places found"
|
||||
message="We couldn't find any places matching your search nearby."
|
||||
ctaText="Refresh"
|
||||
onCtaClick={fetchPlaces}
|
||||
/>
|
||||
) : (
|
||||
filteredPlaces.map((place, index) => (
|
||||
<div
|
||||
key={place.id}
|
||||
onClick={() => history.push(`/reels/${place.id}`)}
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
margin: '0 20px 12px',
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: '112px',
|
||||
height: '88px',
|
||||
borderRadius: 'var(--radius-control)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
place.image ||
|
||||
getStorageUrl('place-media', place.image_path) ||
|
||||
''
|
||||
}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
alt={place.name}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 800,
|
||||
margin: '0 0 4px',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
{index + 1}. {place.name}
|
||||
</h3>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-secondary)',
|
||||
margin: '0 0 4px',
|
||||
}}
|
||||
>
|
||||
{place.category}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
margin: '0 0 8px',
|
||||
}}
|
||||
>
|
||||
{place.distance || '0.9 mi'}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '1px' }}>
|
||||
{[1, 2, 3, 4, 5].map((s) => (
|
||||
<IonIcon
|
||||
key={s}
|
||||
icon={star}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color:
|
||||
s <= place.rating
|
||||
? 'var(--ion-color-primary)'
|
||||
: 'var(--color-border)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{place.review_count > 1000
|
||||
? `${(place.review_count / 1000).toFixed(1)}k`
|
||||
: place.review_count}{' '}
|
||||
Reviews
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{place.busyness === 'busy' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Busy
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--ion-color-primary)',
|
||||
color: '#ffffff',
|
||||
fontSize: '9px',
|
||||
fontWeight: 800,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
}}
|
||||
>
|
||||
LIVE
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default HangoutPage;
|
||||
@@ -1,28 +1,21 @@
|
||||
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>
|
||||
<IonHeader translucent className="home-header">
|
||||
<IonToolbar className="home-toolbar">
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>Home</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent fullscreen className="home-content">
|
||||
<div className="home-shell minimal-home-shell">
|
||||
<section className="minimal-welcome-card subtle-welcome-card">
|
||||
<h1>Welcome to your new app</h1>
|
||||
<p className="minimal-subtitle">
|
||||
Chat with the assistant to start adding features and pages
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
<IonContent className="ion-padding">
|
||||
<p>Welcome! Start building your app here.</p>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,242 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonButtons,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
chevronBackOutline,
|
||||
notificationsOutline,
|
||||
heart,
|
||||
chatbubble,
|
||||
people,
|
||||
flash,
|
||||
} from 'ionicons/icons';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
import { formatTime } from '../utils/format';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import { previewNotifications } from '../utils/previewData';
|
||||
import { isPreviewMode } from '../utils/previewMode';
|
||||
import Lottie from 'lottie-react';
|
||||
import notificationsAnimation from '../assets/images/InSpotNotifications.json';
|
||||
|
||||
const NotificationsPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user } = useAuth();
|
||||
const previewMode = isPreviewMode();
|
||||
const [notifications, setNotifications] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
if (previewMode) {
|
||||
setNotifications(previewNotifications);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('notifications')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setNotifications(data || []);
|
||||
|
||||
// Mark as read after a short delay
|
||||
if (data && data.some((n) => !n.read_at)) {
|
||||
setTimeout(async () => {
|
||||
await supabase
|
||||
.from('notifications')
|
||||
.update({ read_at: new Date().toISOString() })
|
||||
.eq('user_id', user.id)
|
||||
.is('read_at', null);
|
||||
}, 2000);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Fetch notifications error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
fetchNotifications();
|
||||
});
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'reel_like':
|
||||
return heart;
|
||||
case 'reel_comment':
|
||||
return chatbubble;
|
||||
case 'match':
|
||||
return people;
|
||||
case 'message':
|
||||
return chatbubble;
|
||||
default:
|
||||
return flash;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
|
||||
<IonButtons slot="start">
|
||||
<IonButton
|
||||
onClick={() => history.goBack()}
|
||||
style={{ '--color': 'var(--color-text-primary)' }}
|
||||
>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle>Notifications</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
|
||||
{previewMode && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '12px 20px 0',
|
||||
}}
|
||||
>
|
||||
<Lottie
|
||||
animationData={notificationsAnimation}
|
||||
loop
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ padding: '16px 20px' }}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<IonSpinner name="crescent" color="primary" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={notificationsOutline}
|
||||
title="No notifications yet"
|
||||
message="When you get likes, matches, or messages, they'll show up here."
|
||||
ctaText="Go discover"
|
||||
onCtaClick={() => history.push('/icebreaker')}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
||||
>
|
||||
{notifications.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => {
|
||||
if (previewMode && !n.read_at) {
|
||||
setNotifications((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === n.id
|
||||
? { ...item, read_at: new Date().toISOString() }
|
||||
: item
|
||||
)
|
||||
);
|
||||
}
|
||||
if (n.deep_link) history.push(n.deep_link);
|
||||
}}
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
opacity: n.read_at ? 0.7 : 1,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-accent-soft)',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={getIcon(n.type)}
|
||||
style={{
|
||||
color: 'var(--ion-color-primary)',
|
||||
fontSize: '20px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
{n.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'var(--color-text-secondary)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{n.body}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
marginTop: '4px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{formatTime(n.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
{!n.read_at && (
|
||||
<div
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
background: 'var(--ion-color-primary)',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsPage;
|
||||
@@ -1,318 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonButton,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
IonSelect,
|
||||
IonSelectOption,
|
||||
IonTextarea,
|
||||
} from '@ionic/react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
import Avatar from '../components/Avatar';
|
||||
|
||||
const OnboardingProfilePage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user, refreshProfile } = useAuth();
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [birthDate, setBirthDate] = useState('');
|
||||
const [bio, setBio] = useState('');
|
||||
const [gender, setGender] = useState('');
|
||||
const [relationshipGoal, setRelationshipGoal] = useState('');
|
||||
const [avatarPath, setAvatarPath] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
if (!user) history.replace('/auth');
|
||||
});
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setError(msg);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !user) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${user.id}-${Math.random()}.${fileExt}`;
|
||||
const filePath = `avatars/${fileName}`;
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('avatars')
|
||||
.upload(filePath, file);
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
setAvatarPath(filePath);
|
||||
} catch (err: any) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!fullName || !birthDate || !gender || !relationshipGoal || !user) {
|
||||
showError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { error } = await supabase.from('profiles').upsert({
|
||||
id: user.id,
|
||||
full_name: fullName,
|
||||
birth_date: birthDate,
|
||||
bio,
|
||||
gender,
|
||||
relationship_goal: relationshipGoal,
|
||||
avatar_path: avatarPath,
|
||||
onboarding_complete: true,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
await refreshProfile();
|
||||
history.push('/icebreaker');
|
||||
} catch (err: any) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goals = [
|
||||
{ label: 'Long-term partner', value: 'long_term' },
|
||||
{ label: 'Long-term, but short-term OK', value: 'long_term_short_ok' },
|
||||
{ label: 'Short-term, but long-term OK', value: 'short_term_long_ok' },
|
||||
{ label: 'Short-term fun', value: 'short_term' },
|
||||
{ label: 'New friends', value: 'friends' },
|
||||
{ label: 'Still figuring it out', value: 'figuring_it_out' },
|
||||
];
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
|
||||
<div
|
||||
style={{
|
||||
padding:
|
||||
'calc(var(--ion-safe-area-top) + 40px) 20px calc(var(--ion-safe-area-bottom) + 40px)',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '32px', textAlign: 'center' }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: 800,
|
||||
color: 'var(--color-text-primary)',
|
||||
margin: '0 0 8px',
|
||||
}}
|
||||
>
|
||||
Create your profile
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
color: 'var(--color-text-secondary)',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Let others know who you are.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginBottom: '32px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Avatar
|
||||
src={avatarPath}
|
||||
name={fullName}
|
||||
size={120}
|
||||
style={{ border: '4px solid #ffffff' }}
|
||||
/>
|
||||
<label
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
background: 'var(--ion-color-primary)',
|
||||
color: '#ffffff',
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
border: '3px solid #ffffff',
|
||||
}}
|
||||
>
|
||||
{uploading ? <IonSpinner name="crescent" /> : '+'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
hidden
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
padding: '24px',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<IonItem
|
||||
lines="none"
|
||||
style={{
|
||||
'--background': 'var(--color-bg)',
|
||||
'--border-radius': 'var(--radius-control)',
|
||||
}}
|
||||
>
|
||||
<IonInput
|
||||
label="Full Name"
|
||||
labelPlacement="floating"
|
||||
value={fullName}
|
||||
onIonInput={(e) => setFullName(e.detail.value!)}
|
||||
required
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<IonItem
|
||||
lines="none"
|
||||
style={{
|
||||
'--background': 'var(--color-bg)',
|
||||
'--border-radius': 'var(--radius-control)',
|
||||
}}
|
||||
>
|
||||
<IonInput
|
||||
label="Birth Date"
|
||||
labelPlacement="floating"
|
||||
type="date"
|
||||
value={birthDate}
|
||||
onIonInput={(e) => setBirthDate(e.detail.value!)}
|
||||
required
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<IonItem
|
||||
lines="none"
|
||||
style={{
|
||||
'--background': 'var(--color-bg)',
|
||||
'--border-radius': 'var(--radius-control)',
|
||||
}}
|
||||
>
|
||||
<IonSelect
|
||||
label="Gender"
|
||||
labelPlacement="floating"
|
||||
value={gender}
|
||||
onIonChange={(e) => setGender(e.detail.value)}
|
||||
interface="action-sheet"
|
||||
>
|
||||
<IonSelectOption value="woman">Woman</IonSelectOption>
|
||||
<IonSelectOption value="man">Man</IonSelectOption>
|
||||
<IonSelectOption value="nonbinary">Non-binary</IonSelectOption>
|
||||
<IonSelectOption value="other">Other</IonSelectOption>
|
||||
<IonSelectOption value="prefer_not_to_say">
|
||||
Prefer not to say
|
||||
</IonSelectOption>
|
||||
</IonSelect>
|
||||
</IonItem>
|
||||
|
||||
<IonItem
|
||||
lines="none"
|
||||
style={{
|
||||
'--background': 'var(--color-bg)',
|
||||
'--border-radius': 'var(--radius-control)',
|
||||
}}
|
||||
>
|
||||
<IonSelect
|
||||
label="What are you looking for?"
|
||||
labelPlacement="floating"
|
||||
value={relationshipGoal}
|
||||
onIonChange={(e) => setRelationshipGoal(e.detail.value)}
|
||||
interface="action-sheet"
|
||||
>
|
||||
{goals.map((g) => (
|
||||
<IonSelectOption key={g.value} value={g.value}>
|
||||
{g.label}
|
||||
</IonSelectOption>
|
||||
))}
|
||||
</IonSelect>
|
||||
</IonItem>
|
||||
|
||||
<IonItem
|
||||
lines="none"
|
||||
style={{
|
||||
'--background': 'var(--color-bg)',
|
||||
'--border-radius': 'var(--radius-control)',
|
||||
}}
|
||||
>
|
||||
<IonTextarea
|
||||
label="Bio"
|
||||
labelPlacement="floating"
|
||||
value={bio}
|
||||
onIonInput={(e) => setBio(e.detail.value!)}
|
||||
placeholder="Write something about yourself..."
|
||||
rows={4}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(220, 38, 38, 0.08)',
|
||||
color: 'var(--ion-color-danger)',
|
||||
padding: '12px',
|
||||
borderRadius: 'var(--radius-control)',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
disabled={loading || uploading}
|
||||
onClick={handleComplete}
|
||||
style={{
|
||||
'--border-radius': 'var(--radius-pill)',
|
||||
height: '48px',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
{loading ? <IonSpinner name="crescent" /> : 'Complete Profile'}
|
||||
</IonButton>
|
||||
</div>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingProfilePage;
|
||||
@@ -1,300 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonButtons,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
chevronBackOutline,
|
||||
checkmarkCircle,
|
||||
cardOutline,
|
||||
infiniteOutline,
|
||||
eyeOutline,
|
||||
heartOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
import { formatCurrency } from '../utils/format';
|
||||
import { createCheckout, pollPaymentStatus } from '../utils/payments';
|
||||
|
||||
const PremiumPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<
|
||||
'idle' | 'opening' | 'pending' | 'paid' | 'failed'
|
||||
>('idle');
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
});
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
setLoading(true);
|
||||
setStatus('opening');
|
||||
try {
|
||||
const { checkoutUrl, paymentId } = await createCheckout(9.99, 'premium');
|
||||
|
||||
// Phase 1 stub behavior: simulate payment by following stub return
|
||||
window.open(checkoutUrl, '_blank');
|
||||
|
||||
setStatus('pending');
|
||||
const result = await pollPaymentStatus(paymentId);
|
||||
|
||||
if (result === 'paid') {
|
||||
setStatus('paid');
|
||||
setTimeout(() => history.goBack(), 2000);
|
||||
} else {
|
||||
setStatus('failed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Payment error:', err);
|
||||
setStatus('failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const benefits = [
|
||||
{
|
||||
icon: infiniteOutline,
|
||||
title: 'Unlimited Swipes',
|
||||
desc: 'Swipe as much as you want.',
|
||||
},
|
||||
{
|
||||
icon: eyeOutline,
|
||||
title: 'See Who Likes You',
|
||||
desc: 'Check out your admirers instantly.',
|
||||
},
|
||||
{
|
||||
icon: heartOutline,
|
||||
title: '5 Super Likes / Day',
|
||||
desc: 'Stand out from the crowd.',
|
||||
},
|
||||
{
|
||||
icon: checkmarkCircle,
|
||||
title: 'Verified Badge',
|
||||
desc: 'Get more trust and matches.',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
|
||||
<IonButtons slot="start">
|
||||
<IonButton
|
||||
onClick={() => history.goBack()}
|
||||
style={{ '--color': 'var(--color-text-primary)' }}
|
||||
>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle>InSpot Premium</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
|
||||
<div style={{ padding: '24px 20px' }}>
|
||||
{/* Pricing Card */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
padding: '32px 24px',
|
||||
textAlign: 'center',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
marginBottom: '32px',
|
||||
border: '2px solid var(--ion-color-primary)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: 800,
|
||||
color: 'var(--ion-color-primary)',
|
||||
marginBottom: '8px',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Full Access
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'center',
|
||||
gap: '4px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '40px',
|
||||
fontWeight: 800,
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
$9.99
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
/ month
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'var(--color-text-secondary)',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Cancel anytime. No hidden fees.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 800,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
What's included
|
||||
</h3>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
marginBottom: '40px',
|
||||
}}
|
||||
>
|
||||
{benefits.map((b, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '16px' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-accent-soft)',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={b.icon}
|
||||
style={{
|
||||
color: 'var(--ion-color-primary)',
|
||||
fontSize: '22px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
{b.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{b.desc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{status === 'paid' && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(22, 163, 74, 0.08)',
|
||||
color: 'var(--ion-color-success)',
|
||||
padding: '16px',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
textAlign: 'center',
|
||||
marginBottom: '24px',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Payment successful! Welcome to Premium.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'failed' && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(220, 38, 38, 0.08)',
|
||||
color: 'var(--ion-color-danger)',
|
||||
padding: '16px',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
textAlign: 'center',
|
||||
marginBottom: '24px',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Payment failed or cancelled. Please try again.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: '#ffffff',
|
||||
padding: '16px 20px calc(16px + var(--ion-safe-area-bottom))',
|
||||
borderTop: '1px solid rgba(76, 5, 25, 0.05)',
|
||||
}}
|
||||
>
|
||||
<IonButton
|
||||
expand="block"
|
||||
disabled={loading || status === 'paid'}
|
||||
onClick={handleSubscribe}
|
||||
style={{
|
||||
'--border-radius': 'var(--radius-pill)',
|
||||
height: '52px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{loading ? <IonSpinner name="crescent" /> : 'Get Premium Now'}
|
||||
</IonButton>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default PremiumPage;
|
||||
@@ -1,497 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonIcon,
|
||||
IonButton,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
IonAlert,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonList,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
settingsOutline,
|
||||
notificationsOutline,
|
||||
checkmarkCircle,
|
||||
chevronForwardOutline,
|
||||
cardOutline,
|
||||
shieldCheckmarkOutline,
|
||||
logOutOutline,
|
||||
trashOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
import Avatar from '../components/Avatar';
|
||||
import { getStorageUrl } from '../utils/storage';
|
||||
import { previewCurrentUser, previewReels } from '../utils/previewData';
|
||||
import { isPreviewMode, disablePreviewMode } from '../utils/previewMode';
|
||||
|
||||
const ProfilePage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user, profile, signOut } = useAuth();
|
||||
const previewMode = isPreviewMode();
|
||||
const displayProfile = previewMode && !profile ? previewCurrentUser : profile;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reels, setReels] = useState<any[]>([]);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (previewMode && !user) {
|
||||
setReels(previewReels);
|
||||
return;
|
||||
}
|
||||
if (!user) return;
|
||||
const { data } = await supabase
|
||||
.from('reels')
|
||||
.select('id, media_path')
|
||||
.eq('author_id', user.id)
|
||||
.limit(6);
|
||||
setReels(data || []);
|
||||
};
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
fetchData();
|
||||
});
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { error } = await supabase.functions.invoke('delete-account', {
|
||||
body: { confirm: true },
|
||||
});
|
||||
if (error) throw error;
|
||||
await signOut();
|
||||
history.replace('/auth');
|
||||
} catch (err: any) {
|
||||
console.error('Delete error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 800,
|
||||
color: 'var(--color-text-primary)',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Profile
|
||||
</h1>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<IonButton
|
||||
fill="clear"
|
||||
onClick={() => history.push('/notifications')}
|
||||
style={{
|
||||
'--background': 'var(--color-surface)',
|
||||
'--color': 'var(--color-text-primary)',
|
||||
'--border-radius': 'var(--radius-control)',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={notificationsOutline}
|
||||
slot="icon-only"
|
||||
style={{ fontSize: '20px' }}
|
||||
/>
|
||||
</IonButton>
|
||||
<IonButton
|
||||
fill="clear"
|
||||
style={{
|
||||
'--background': 'var(--color-surface)',
|
||||
'--color': 'var(--color-text-primary)',
|
||||
'--border-radius': 'var(--radius-control)',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={settingsOutline}
|
||||
slot="icon-only"
|
||||
style={{ fontSize: '20px' }}
|
||||
/>
|
||||
</IonButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 20px 24px' }}>
|
||||
{/* Profile Card */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
padding: '24px',
|
||||
textAlign: 'center',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
src={displayProfile?.avatar_path}
|
||||
name={displayProfile?.full_name}
|
||||
size={96}
|
||||
style={{ border: '4px solid var(--color-bg)' }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
background: '#ffffff',
|
||||
borderRadius: '50%',
|
||||
padding: '2px',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={checkmarkCircle}
|
||||
style={{
|
||||
color: 'var(--ion-color-success)',
|
||||
fontSize: '24px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '22px',
|
||||
fontWeight: 800,
|
||||
color: 'var(--color-text-primary)',
|
||||
margin: '0 0 4px',
|
||||
}}
|
||||
>
|
||||
{displayProfile?.full_name || 'InSpot User'}
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
background: 'var(--color-accent-soft)',
|
||||
color: 'var(--ion-color-primary)',
|
||||
padding: '4px 12px',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
Long-term partner
|
||||
</div>
|
||||
<IonButton
|
||||
expand="block"
|
||||
onClick={() => history.push('/profile/edit')}
|
||||
style={{
|
||||
'--border-radius': 'var(--radius-pill)',
|
||||
height: '44px',
|
||||
'--background': 'var(--color-surface-raised)',
|
||||
'--color': 'var(--ion-color-primary)',
|
||||
'--box-shadow': 'none',
|
||||
}}
|
||||
>
|
||||
Edit Profile
|
||||
</IonButton>
|
||||
</div>
|
||||
|
||||
{/* Premium CTA */}
|
||||
<div
|
||||
onClick={() => history.push('/premium')}
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, var(--ion-color-primary) 0%, var(--ion-color-primary-dark) 100%)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
padding: '20px',
|
||||
color: '#ffffff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '24px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IonIcon icon={cardOutline} style={{ fontSize: '24px' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '15px', fontWeight: 800 }}>
|
||||
InSpot Premium
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.8 }}>
|
||||
Get unlimited swipes and see who likes you.
|
||||
</div>
|
||||
</div>
|
||||
<IonIcon
|
||||
icon={chevronForwardOutline}
|
||||
style={{ fontSize: '20px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* My Reels */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 800,
|
||||
margin: 0,
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
My Reels
|
||||
</h3>
|
||||
<IonButton
|
||||
fill="clear"
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
'--color': 'var(--ion-color-primary)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
See all
|
||||
</IonButton>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
{reels.map((reel) => (
|
||||
<div
|
||||
key={reel.id}
|
||||
style={{
|
||||
paddingBottom: '133%',
|
||||
background: 'var(--color-surface-raised)',
|
||||
borderRadius: '12px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
reel.image ||
|
||||
getStorageUrl('reels', reel.media_path) ||
|
||||
''
|
||||
}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
alt="Reel"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
onClick={() => history.push('/camera')}
|
||||
style={{
|
||||
paddingBottom: '133%',
|
||||
background: 'var(--color-surface-raised)',
|
||||
borderRadius: '12px',
|
||||
position: 'relative',
|
||||
border: '2px dashed var(--ion-color-primary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={trashOutline}
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
color: 'var(--ion-color-primary)',
|
||||
transform: 'rotate(45deg)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings List */}
|
||||
<IonList
|
||||
lines="none"
|
||||
style={{ background: 'transparent', padding: 0 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
padding: '8px',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
}}
|
||||
>
|
||||
<IonItem button detail={false} onClick={() => {}}>
|
||||
<div
|
||||
slot="start"
|
||||
style={{
|
||||
background: 'var(--color-accent-soft)',
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: '12px',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={shieldCheckmarkOutline}
|
||||
color="primary"
|
||||
style={{ fontSize: '20px' }}
|
||||
/>
|
||||
</div>
|
||||
<IonLabel style={{ fontSize: '15px', fontWeight: 600 }}>
|
||||
Privacy & Safety
|
||||
</IonLabel>
|
||||
<IonIcon
|
||||
icon={chevronForwardOutline}
|
||||
slot="end"
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
}}
|
||||
/>
|
||||
</IonItem>
|
||||
<IonItem
|
||||
button
|
||||
detail={false}
|
||||
onClick={() => {
|
||||
localStorage.removeItem('inspot-preview-mode');
|
||||
signOut();
|
||||
history.replace('/auth');
|
||||
}}
|
||||
>
|
||||
<div
|
||||
slot="start"
|
||||
style={{
|
||||
background: 'var(--color-bg)',
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: '12px',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={logOutOutline}
|
||||
color="dark"
|
||||
style={{ fontSize: '20px' }}
|
||||
/>
|
||||
</div>
|
||||
<IonLabel style={{ fontSize: '15px', fontWeight: 600 }}>
|
||||
Sign Out
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
<IonItem
|
||||
button
|
||||
detail={false}
|
||||
onClick={() => setShowDeleteAlert(true)}
|
||||
style={{ '--color': 'var(--ion-color-danger)' }}
|
||||
>
|
||||
<div
|
||||
slot="start"
|
||||
style={{
|
||||
background: 'rgba(220, 38, 38, 0.08)',
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: '12px',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={trashOutline}
|
||||
color="danger"
|
||||
style={{ fontSize: '20px' }}
|
||||
/>
|
||||
</div>
|
||||
<IonLabel style={{ fontSize: '15px', fontWeight: 600 }}>
|
||||
Delete Account
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
</div>
|
||||
</IonList>
|
||||
</div>
|
||||
|
||||
<IonAlert
|
||||
isOpen={showDeleteAlert}
|
||||
onDidDismiss={() => setShowDeleteAlert(false)}
|
||||
header="Delete Account"
|
||||
message="Are you sure you want to permanently delete your account? This action cannot be undone."
|
||||
buttons={[
|
||||
{ text: 'Cancel', role: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
role: 'destructive',
|
||||
handler: handleDeleteAccount,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
@@ -1,481 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonIcon,
|
||||
IonButton,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
IonModal,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonButtons,
|
||||
IonItem,
|
||||
IonInput,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
closeOutline,
|
||||
heartOutline,
|
||||
heart,
|
||||
chatbubbleOutline,
|
||||
shareSocialOutline,
|
||||
logoWhatsapp,
|
||||
pinOutline,
|
||||
send,
|
||||
} from 'ionicons/icons';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
import { formatCompactNumber, formatDate } from '../utils/format';
|
||||
import { getStorageUrl } from '../utils/storage';
|
||||
import Avatar from '../components/Avatar';
|
||||
import { previewPlaces, previewReels } from '../utils/previewData';
|
||||
import { isPreviewMode } from '../utils/previewMode';
|
||||
|
||||
const ReelsPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { placeId } = useParams<{ placeId: string }>();
|
||||
const { user } = useAuth();
|
||||
const [reels, setReels] = useState<any[]>([]);
|
||||
const [place, setPlace] = useState<any>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [commentsOpen, setCommentsOpen] = useState(false);
|
||||
const [commentDraft, setCommentDraft] = useState('');
|
||||
const [comments, setComments] = useState<any[]>([]);
|
||||
const [likedReels, setLikedReels] = useState<Set<string>>(new Set());
|
||||
const previewMode = isPreviewMode();
|
||||
|
||||
const fetchReels = async () => {
|
||||
setLoading(true);
|
||||
if (previewMode) {
|
||||
setPlace(previewPlaces.find((p) => p.id === placeId) || previewPlaces[0]);
|
||||
setReels(previewReels);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data: placeData } = await supabase
|
||||
.from('places')
|
||||
.select('*')
|
||||
.eq('id', placeId)
|
||||
.single();
|
||||
setPlace(placeData);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('reels')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
author:profiles(id, full_name, avatar_path)
|
||||
`
|
||||
)
|
||||
.eq('place_id', placeId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setReels(data || []);
|
||||
|
||||
if (user) {
|
||||
const { data: likes } = await supabase
|
||||
.from('reel_likes')
|
||||
.select('reel_id')
|
||||
.eq('user_id', user.id);
|
||||
setLikedReels(new Set(likes?.map((l) => l.reel_id) || []));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Fetch reels error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
fetchReels();
|
||||
});
|
||||
|
||||
const handleLike = async (reelId: string) => {
|
||||
if (!user && !previewMode) return;
|
||||
const isLiked = likedReels.has(reelId);
|
||||
|
||||
// Optimistic update
|
||||
const newLiked = new Set(likedReels);
|
||||
if (isLiked) newLiked.delete(reelId);
|
||||
else newLiked.add(reelId);
|
||||
setLikedReels(newLiked);
|
||||
|
||||
if (previewMode) return;
|
||||
|
||||
try {
|
||||
if (isLiked) {
|
||||
await supabase
|
||||
.from('reel_likes')
|
||||
.delete()
|
||||
.eq('reel_id', reelId)
|
||||
.eq('user_id', user.id);
|
||||
} else {
|
||||
await supabase
|
||||
.from('reel_likes')
|
||||
.insert({ reel_id: reelId, user_id: user.id });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Like error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const currentReel = reels[activeIndex];
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent fullscreen style={{ '--background': '#000000' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(14px + var(--ion-safe-area-top))',
|
||||
left: '20px',
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<IonButton
|
||||
fill="clear"
|
||||
onClick={() => history.goBack()}
|
||||
style={{
|
||||
'--background': 'rgba(0,0,0,0.3)',
|
||||
'--color': '#ffffff',
|
||||
'--border-radius': '50%',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon icon={closeOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
<span style={{ color: '#ffffff', fontWeight: 800, fontSize: '18px' }}>
|
||||
Reels
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
background: '#000000',
|
||||
}}
|
||||
>
|
||||
<IonSpinner name="crescent" color="primary" />
|
||||
</div>
|
||||
) : reels.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: '#ffffff',
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<p>No reels found for this place.</p>
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill="outline"
|
||||
color="light"
|
||||
onClick={() => history.push('/camera')}
|
||||
>
|
||||
Add the first reel
|
||||
</IonButton>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() =>
|
||||
reels.length > 1 &&
|
||||
setActiveIndex((prev) => (prev + 1) % reels.length)
|
||||
}
|
||||
style={{ height: '100%', width: '100%', position: 'relative' }}
|
||||
>
|
||||
{/* Media Area */}
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
{currentReel.media_type === 'video' ? (
|
||||
<video
|
||||
src={
|
||||
currentReel.image ||
|
||||
getStorageUrl('reels', currentReel.media_path) ||
|
||||
''
|
||||
}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={
|
||||
currentReel.image ||
|
||||
getStorageUrl('reels', currentReel.media_path) ||
|
||||
''
|
||||
}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
alt="Reel content"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Rail */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '12px',
|
||||
bottom: '150px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleLike(currentReel.id);
|
||||
}}
|
||||
style={{ textAlign: 'center' }}
|
||||
>
|
||||
<IonIcon
|
||||
icon={likedReels.has(currentReel.id) ? heart : heartOutline}
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
color: likedReels.has(currentReel.id)
|
||||
? 'var(--ion-color-primary)'
|
||||
: '#ffffff',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
color: '#ffffff',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{formatCompactNumber(currentReel.likes_count || 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setCommentsOpen(true);
|
||||
}}
|
||||
style={{ textAlign: 'center' }}
|
||||
>
|
||||
<IonIcon
|
||||
icon={chatbubbleOutline}
|
||||
style={{ fontSize: '30px', color: '#ffffff' }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
color: '#ffffff',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{formatCompactNumber(currentReel.comments_count || 0)}
|
||||
</div>
|
||||
</div>
|
||||
<IonIcon
|
||||
icon={shareSocialOutline}
|
||||
style={{ fontSize: '28px', color: '#ffffff' }}
|
||||
/>
|
||||
<IonIcon
|
||||
icon={logoWhatsapp}
|
||||
style={{ fontSize: '28px', color: '#ffffff' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Caption Overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(32px + var(--ion-safe-area-bottom))',
|
||||
left: '20px',
|
||||
right: '88px',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--ion-color-primary)',
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={pinOutline}
|
||||
style={{ fontSize: '18px', color: '#ffffff' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 800 }}>
|
||||
{place?.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', opacity: 0.7 }}>
|
||||
{formatDate(currentReel.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
src={currentReel.author.avatar_path}
|
||||
name={currentReel.author.full_name}
|
||||
size={42}
|
||||
/>
|
||||
<span style={{ fontSize: '15px', fontWeight: 700 }}>
|
||||
{currentReel.author.full_name}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
margin: 0,
|
||||
opacity: 0.9,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{currentReel.caption}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Simple Next/Prev Controls for Phase 1 */}
|
||||
{reels.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100px',
|
||||
right: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<IonButton
|
||||
size="small"
|
||||
fill="clear"
|
||||
disabled={activeIndex === 0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setActiveIndex((prev) => prev - 1);
|
||||
}}
|
||||
style={{ '--color': '#ffffff' }}
|
||||
>
|
||||
Prev
|
||||
</IonButton>
|
||||
<IonButton
|
||||
size="small"
|
||||
fill="clear"
|
||||
disabled={activeIndex === reels.length - 1}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setActiveIndex((prev) => prev + 1);
|
||||
}}
|
||||
style={{ '--color': '#ffffff' }}
|
||||
>
|
||||
Next
|
||||
</IonButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments Sheet */}
|
||||
<IonModal
|
||||
isOpen={commentsOpen}
|
||||
onDidDismiss={() => setCommentsOpen(false)}
|
||||
initialBreakpoint={0.5}
|
||||
breakpoints={[0, 0.5, 0.9]}
|
||||
>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>Comments</IonTitle>
|
||||
<IonButtons slot="end">
|
||||
<IonButton onClick={() => setCommentsOpen(false)}>
|
||||
Close
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent className="ion-padding">
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: 'var(--color-text-secondary)',
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
No comments yet. Be the first to comment!
|
||||
</div>
|
||||
{/* Input area fixed at bottom of modal content */}
|
||||
<div
|
||||
style={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
background: '#ffffff',
|
||||
padding: '12px 0',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
|
||||
>
|
||||
<IonInput
|
||||
value={commentDraft}
|
||||
onIonInput={(e) => setCommentDraft(e.detail.value!)}
|
||||
placeholder="Add a comment..."
|
||||
style={{
|
||||
background: 'var(--color-bg)',
|
||||
'--border-radius': 'var(--radius-pill)',
|
||||
'--padding-start': '16px',
|
||||
}}
|
||||
/>
|
||||
<IonButton fill="clear" disabled={!commentDraft.trim()}>
|
||||
<IonIcon icon={send} slot="icon-only" color="primary" />
|
||||
</IonButton>
|
||||
</div>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonModal>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReelsPage;
|
||||
@@ -1,200 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonButton,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
import OtpInput from '../components/OtpInput';
|
||||
|
||||
const VerifyEmailPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const location = useLocation<{
|
||||
email: string;
|
||||
type: 'signup' | 'recovery';
|
||||
}>();
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cooldown, setCooldown] = useState(60);
|
||||
|
||||
const email = location.state?.email;
|
||||
const type = location.state?.type || 'signup';
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
if (!email) {
|
||||
history.replace('/auth');
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldown > 0) {
|
||||
const timer = setTimeout(() => setCooldown(cooldown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [cooldown]);
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setError(msg);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (code.length !== 6) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (type === 'recovery') {
|
||||
const params: any = {
|
||||
email,
|
||||
token: code,
|
||||
type: 'recovery',
|
||||
};
|
||||
result = await (supabase.auth as any).verifyOtp(params);
|
||||
} else {
|
||||
result = await supabase.auth.verifyOtp({
|
||||
email,
|
||||
token: code,
|
||||
type: 'email',
|
||||
});
|
||||
}
|
||||
|
||||
if (result.error) throw result.error;
|
||||
|
||||
if (type === 'recovery') {
|
||||
history.push('/new-password', { email });
|
||||
} else {
|
||||
history.push('/onboarding/profile');
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
if (cooldown > 0) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const resendParams: any = {
|
||||
type: type === 'signup' ? 'email' : 'recovery',
|
||||
email,
|
||||
};
|
||||
const { error } = await supabase.auth.resend(resendParams);
|
||||
if (error) throw error;
|
||||
setCooldown(60);
|
||||
} catch (err: any) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent
|
||||
fullscreen
|
||||
style={{
|
||||
'--background': 'var(--color-bg)',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: 'calc(var(--ion-safe-area-top) + 40px) 20px' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: 800,
|
||||
color: 'var(--color-text-primary)',
|
||||
margin: '0 0 8px',
|
||||
}}
|
||||
>
|
||||
Check your email
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
color: 'var(--color-text-secondary)',
|
||||
margin: 0,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
We've sent a 6-digit verification code to{' '}
|
||||
<span
|
||||
style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}
|
||||
>
|
||||
{email}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
padding: '24px',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<OtpInput value={code} onChange={setCode} disabled={loading} />
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(220, 38, 38, 0.08)',
|
||||
color: 'var(--ion-color-danger)',
|
||||
padding: '12px',
|
||||
borderRadius: 'var(--radius-control)',
|
||||
fontSize: '13px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
disabled={loading || code.length !== 6}
|
||||
onClick={handleVerify}
|
||||
style={{
|
||||
'--border-radius': 'var(--radius-pill)',
|
||||
height: '48px',
|
||||
}}
|
||||
>
|
||||
{loading ? <IonSpinner name="crescent" /> : 'Verify'}
|
||||
</IonButton>
|
||||
|
||||
<button
|
||||
onClick={handleResend}
|
||||
disabled={loading || cooldown > 0}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color:
|
||||
cooldown > 0
|
||||
? 'var(--color-text-tertiary)'
|
||||
: 'var(--ion-color-primary)',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
marginTop: '20px',
|
||||
cursor: cooldown > 0 ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyEmailPage;
|
||||
@@ -1,7 +0,0 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import type { Database } from './database.types';
|
||||
|
||||
export const supabase = createClient<Database>(
|
||||
'https://yunudlmsairdxxlhfyci.supabase.co',
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1bnVkbG1zYWlyZHh4bGhmeWNpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODExNDk2OTUsImV4cCI6MjA5NjcyNTY5NX0.WRYh_r5YuruaGrgfAR6Tg_CrSI1CPyYgpvrJQVTbyUE',
|
||||
);
|
||||
@@ -1,51 +0,0 @@
|
||||
// Ambient test globals for optional *.test.* files in the editor / tsc.
|
||||
// No test runner is installed in the template; stubs avoid spurious TS errors.
|
||||
|
||||
interface Matchers<R = void> {
|
||||
toBeDefined(): R;
|
||||
toBe(expected: unknown): R;
|
||||
toEqual(expected: unknown): R;
|
||||
toBeTruthy(): R;
|
||||
toBeFalsy(): R;
|
||||
toBeNull(): R;
|
||||
toContain(item: unknown): R;
|
||||
toHaveLength(length: number): R;
|
||||
toMatch(expected: string | RegExp): R;
|
||||
toThrow(expected?: string | RegExp | Error): R;
|
||||
not: Matchers<R>;
|
||||
}
|
||||
|
||||
interface Expect {
|
||||
<T = unknown>(actual: T): Matchers;
|
||||
}
|
||||
|
||||
declare const expect: Expect;
|
||||
|
||||
type TestCaseFn = (
|
||||
name: string,
|
||||
fn?: () => void | Promise<void>,
|
||||
timeout?: number,
|
||||
) => void;
|
||||
|
||||
declare const test: TestCaseFn;
|
||||
declare const it: TestCaseFn;
|
||||
declare const describe: TestCaseFn;
|
||||
|
||||
declare function beforeEach(fn: () => void | Promise<void>): void;
|
||||
declare function afterEach(fn: () => void | Promise<void>): void;
|
||||
declare function beforeAll(fn: () => void | Promise<void>): void;
|
||||
declare function afterAll(fn: () => void | Promise<void>): void;
|
||||
|
||||
declare module "@testing-library/react" {
|
||||
export function render(
|
||||
ui: unknown,
|
||||
options?: object,
|
||||
): {
|
||||
baseElement: Element;
|
||||
container: Element;
|
||||
unmount(): void;
|
||||
rerender(ui: unknown): void;
|
||||
};
|
||||
export const screen: Record<string, (...args: unknown[]) => Element>;
|
||||
export function cleanup(): void;
|
||||
}
|
||||
@@ -1,205 +1,65 @@
|
||||
/* InSpot exact theme: high-contrast black/white with Tinder Pink accents */
|
||||
/* Ionic CSS Variables — customise to match your app's brand */
|
||||
:root {
|
||||
--ion-color-primary: #fe3c72;
|
||||
--ion-color-primary-rgb: 254, 60, 114;
|
||||
--ion-color-primary: #3880ff;
|
||||
--ion-color-primary-rgb: 56, 128, 255;
|
||||
--ion-color-primary-contrast: #ffffff;
|
||||
--ion-color-primary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-primary-shade: #e03564;
|
||||
--ion-color-primary-tint: #fe507f;
|
||||
--ion-color-primary-shade: #3171e0;
|
||||
--ion-color-primary-tint: #4c8dff;
|
||||
|
||||
--ion-color-secondary: #ef4a75;
|
||||
--ion-color-secondary-rgb: 239, 74, 117;
|
||||
--ion-color-secondary: #3dc2ff;
|
||||
--ion-color-secondary-rgb: 61, 194, 255;
|
||||
--ion-color-secondary-contrast: #ffffff;
|
||||
--ion-color-secondary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-secondary-shade: #d24167;
|
||||
--ion-color-secondary-tint: #f15c83;
|
||||
--ion-color-secondary-shade: #36abe0;
|
||||
--ion-color-secondary-tint: #50c8ff;
|
||||
|
||||
--ion-color-tertiary: #9e9e9e;
|
||||
--ion-color-tertiary-rgb: 158, 158, 158;
|
||||
--ion-color-tertiary-contrast: #000000;
|
||||
--ion-color-tertiary-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-tertiary-shade: #8b8b8b;
|
||||
--ion-color-tertiary-tint: #a8a8a8;
|
||||
--ion-color-tertiary: #5260ff;
|
||||
--ion-color-tertiary-rgb: 82, 96, 255;
|
||||
--ion-color-tertiary-contrast: #ffffff;
|
||||
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-tertiary-shade: #4854e0;
|
||||
--ion-color-tertiary-tint: #6370ff;
|
||||
|
||||
--ion-color-success: #22c55e;
|
||||
--ion-color-success-rgb: 34, 197, 94;
|
||||
--ion-color-success: #2dd36f;
|
||||
--ion-color-success-rgb: 45, 211, 111;
|
||||
--ion-color-success-contrast: #ffffff;
|
||||
--ion-color-success-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-success-shade: #1eae53;
|
||||
--ion-color-success-tint: #38cb6e;
|
||||
--ion-color-success-shade: #28ba62;
|
||||
--ion-color-success-tint: #42d77d;
|
||||
|
||||
--ion-color-warning: #ffcc00;
|
||||
--ion-color-warning-rgb: 255, 204, 0;
|
||||
--ion-color-warning: #ffc409;
|
||||
--ion-color-warning-rgb: 255, 196, 9;
|
||||
--ion-color-warning-contrast: #000000;
|
||||
--ion-color-warning-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-warning-shade: #e0b400;
|
||||
--ion-color-warning-tint: #ffd11a;
|
||||
--ion-color-warning-shade: #e0ac08;
|
||||
--ion-color-warning-tint: #ffca22;
|
||||
|
||||
--ion-color-danger: #ff4444;
|
||||
--ion-color-danger-rgb: 255, 68, 68;
|
||||
--ion-color-danger: #eb445a;
|
||||
--ion-color-danger-rgb: 235, 68, 90;
|
||||
--ion-color-danger-contrast: #ffffff;
|
||||
--ion-color-danger-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-danger-shade: #e03c3c;
|
||||
--ion-color-danger-tint: #ff5757;
|
||||
--ion-color-danger-shade: #cf3c4f;
|
||||
--ion-color-danger-tint: #ed576b;
|
||||
|
||||
--ion-color-dark: #000000;
|
||||
--ion-color-dark-rgb: 0, 0, 0;
|
||||
--ion-color-dark: #222428;
|
||||
--ion-color-dark-rgb: 34, 36, 40;
|
||||
--ion-color-dark-contrast: #ffffff;
|
||||
--ion-color-dark-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-dark-shade: #000000;
|
||||
--ion-color-dark-tint: #1a1a1a;
|
||||
--ion-color-dark-shade: #1e2023;
|
||||
--ion-color-dark-tint: #383a3e;
|
||||
|
||||
--ion-color-medium: #9e9e9e;
|
||||
--ion-color-medium-rgb: 158, 158, 158;
|
||||
--ion-color-medium-contrast: #000000;
|
||||
--ion-color-medium-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-medium-shade: #8b8b8b;
|
||||
--ion-color-medium-tint: #a8a8a8;
|
||||
--ion-color-medium: #92949c;
|
||||
--ion-color-medium-rgb: 146, 148, 156;
|
||||
--ion-color-medium-contrast: #ffffff;
|
||||
--ion-color-medium-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-medium-shade: #808289;
|
||||
--ion-color-medium-tint: #9d9fa6;
|
||||
|
||||
--ion-color-light: #ffffff;
|
||||
--ion-color-light-rgb: 255, 255, 255;
|
||||
--ion-color-light-contrast: #424242;
|
||||
--ion-color-light-contrast-rgb: 66, 66, 66;
|
||||
--ion-color-light-shade: #e0e0e0;
|
||||
--ion-color-light-tint: #ffffff;
|
||||
|
||||
--color-bg: #000000;
|
||||
--color-bg-light: #ffffff;
|
||||
--color-surface: rgba(44, 53, 57, 0.5);
|
||||
--color-surface-solid: #151a1d;
|
||||
--color-surface-raised: rgba(44, 53, 57, 0.72);
|
||||
--color-input: rgba(44, 53, 57, 0.5);
|
||||
--color-border: #333333;
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #aaaaaa;
|
||||
--color-text-tertiary: #9e9e9e;
|
||||
--color-inactive: #9e9e9e;
|
||||
--color-light-text-primary: #424242;
|
||||
--color-light-text-secondary: #555555;
|
||||
--color-light-text-tertiary: #888888;
|
||||
--color-light-card: #dcdcdc;
|
||||
--color-alert: #ff4444;
|
||||
--color-alert-light: #ff0000;
|
||||
--color-accent-soft: rgba(254, 60, 114, 0.18);
|
||||
--color-accent-softer: rgba(254, 60, 114, 0.1);
|
||||
|
||||
--radius-pill: 999px;
|
||||
--radius-card: 20px;
|
||||
--radius-control: 10px;
|
||||
--shadow-card: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.ion-color-primary {
|
||||
--ion-color-base: var(--ion-color-primary);
|
||||
--ion-color-base-rgb: var(--ion-color-primary-rgb);
|
||||
--ion-color-contrast: var(--ion-color-primary-contrast);
|
||||
--ion-color-contrast-rgb: var(--ion-color-primary-contrast-rgb);
|
||||
--ion-color-shade: var(--ion-color-primary-shade);
|
||||
--ion-color-tint: var(--ion-color-primary-tint);
|
||||
}
|
||||
|
||||
.ion-color-secondary {
|
||||
--ion-color-base: var(--ion-color-secondary);
|
||||
--ion-color-base-rgb: var(--ion-color-secondary-rgb);
|
||||
--ion-color-contrast: var(--ion-color-secondary-contrast);
|
||||
--ion-color-contrast-rgb: var(--ion-color-secondary-contrast-rgb);
|
||||
--ion-color-shade: var(--ion-color-secondary-shade);
|
||||
--ion-color-tint: var(--ion-color-secondary-tint);
|
||||
}
|
||||
|
||||
ion-content {
|
||||
--background: #000000;
|
||||
--color: #ffffff;
|
||||
}
|
||||
|
||||
ion-tab-bar {
|
||||
--background: #101014;
|
||||
--border: none;
|
||||
height: calc(64px + var(--ion-safe-area-bottom));
|
||||
padding-bottom: var(--ion-safe-area-bottom);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
ion-tab-button {
|
||||
--background: #101014;
|
||||
--color: #9e9e9e;
|
||||
--color-selected: #fe3c72;
|
||||
}
|
||||
|
||||
ion-tab-button ion-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
ion-tab-button ion-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
ion-header ion-toolbar {
|
||||
--background: #000000;
|
||||
--border-width: 0;
|
||||
--color: #ffffff;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
ion-header ion-title {
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
--background: transparent;
|
||||
--border-color: #333333;
|
||||
--color: #ffffff;
|
||||
--highlight-color-focused: #fe3c72;
|
||||
}
|
||||
|
||||
ion-list-header {
|
||||
--color: #9e9e9e;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
ion-modal {
|
||||
--background: #1b181f;
|
||||
--border-radius: 20px 20px 0 0;
|
||||
--color: #ffffff;
|
||||
}
|
||||
|
||||
ion-modal::part(content) {
|
||||
background: #1b181f;
|
||||
border-radius: 20px 20px 0 0;
|
||||
}
|
||||
|
||||
ion-searchbar {
|
||||
--background: rgba(44, 53, 57, 0.5);
|
||||
--border-radius: 999px;
|
||||
--box-shadow: none;
|
||||
--color: #ffffff;
|
||||
--placeholder-color: #aaaaaa;
|
||||
--icon-color: #ffffff;
|
||||
--clear-button-color: #9e9e9e;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
ion-button {
|
||||
--background: #fe3c72;
|
||||
--background-activated: #ef4a75;
|
||||
--background-focused: #ef4a75;
|
||||
--background-hover: #ef4a75;
|
||||
--color: #ffffff;
|
||||
--box-shadow: none;
|
||||
text-transform: none;
|
||||
--ion-color-light: #f4f5f8;
|
||||
--ion-color-light-rgb: 244, 245, 248;
|
||||
--ion-color-light-contrast: #000000;
|
||||
--ion-color-light-contrast-rgb: 0, 0, 0;
|
||||
--ion-color-light-shade: #d7d8da;
|
||||
--ion-color-light-tint: #f5f6f9;
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
export const formatAge = (birthDate: string | null) => {
|
||||
if (!birthDate) return null;
|
||||
const today = new Date();
|
||||
const birth = new Date(birthDate);
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const m = today.getMonth() - birth.getMonth();
|
||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
};
|
||||
|
||||
export const formatDistance = (meters: number | null) => {
|
||||
if (meters === null) return null;
|
||||
const miles = meters * 0.000621371;
|
||||
if (miles < 0.1) return 'Very close';
|
||||
return `${miles.toFixed(1)} mi`;
|
||||
};
|
||||
|
||||
export const formatCompactNumber = (num: number) => {
|
||||
return Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
export const formatDate = (date: string | null) => {
|
||||
if (!date) return null;
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
export const formatTime = (date: string | null) => {
|
||||
if (!date) return null;
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
return d.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
} else if (days === 1) {
|
||||
return 'Yesterday';
|
||||
} else if (days < 7) {
|
||||
return d.toLocaleDateString('en-US', { weekday: 'short' });
|
||||
} else {
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
export const formatCurrency = (amount: number, currency = 'USD') => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
}).format(amount);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import { PushNotifications } from '@capacitor/push-notifications';
|
||||
import type {
|
||||
ActionPerformed,
|
||||
PushNotificationSchema,
|
||||
} from '@capacitor/push-notifications';
|
||||
import { supabase } from '../supabase';
|
||||
|
||||
export async function setupPushNotifications(userId: string, history: any) {
|
||||
const handleNotificationTap = (notification: PushNotificationSchema) => {
|
||||
if (notification.data?.deep_link) {
|
||||
history.push(notification.data.deep_link);
|
||||
} else if (notification.data?.type === 'message') {
|
||||
history.push(`/chat/${notification.data.conversation_id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const launch = await (PushNotifications as any).getLaunchNotification();
|
||||
if (launch) handleNotificationTap(launch.notification);
|
||||
|
||||
const status = await PushNotifications.checkPermissions();
|
||||
|
||||
if (status.receive === 'granted') {
|
||||
await PushNotifications.register();
|
||||
} else if (status.receive === 'prompt') {
|
||||
const result = await PushNotifications.requestPermissions();
|
||||
if (result.receive === 'granted') await PushNotifications.register();
|
||||
}
|
||||
|
||||
PushNotifications.addListener('registration', async (token) => {
|
||||
await supabase
|
||||
.from('profiles')
|
||||
.update({ fcm_token: token.value })
|
||||
.eq('id', userId);
|
||||
});
|
||||
|
||||
PushNotifications.addListener('registrationError', (err) => {
|
||||
console.error('Push registration error', err);
|
||||
});
|
||||
|
||||
PushNotifications.addListener(
|
||||
'pushNotificationReceived',
|
||||
async (notification: PushNotificationSchema) => {
|
||||
console.log('Push received', notification);
|
||||
}
|
||||
);
|
||||
|
||||
PushNotifications.addListener(
|
||||
'pushNotificationActionPerformed',
|
||||
(action: ActionPerformed) => {
|
||||
handleNotificationTap(action.notification);
|
||||
}
|
||||
);
|
||||
|
||||
await PushNotifications.removeAllDeliveredNotifications();
|
||||
}
|
||||