Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee7cfdfeaa |
@@ -4,10 +4,10 @@ android {
|
||||
namespace = "io.ionic.starter"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.habitmode.llemba.com"
|
||||
applicationId "com.qoutelink.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionCode 3
|
||||
versionName "1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [googleMapsApiKey: System.getenv("VITE_GOOGLE_MAPS_KEY") ?: ""]
|
||||
|
||||
@@ -1,7 +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.POST_NOTIFICATIONS"/>
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -22,6 +21,12 @@
|
||||
<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.qoutelink.app" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="splashBackground">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">app</string>
|
||||
<string name="title_activity_main">app</string>
|
||||
<string name="app_name">Qoutelink</string>
|
||||
<string name="title_activity_main">Qoutelink</string>
|
||||
<string name="package_name">io.ionic.starter</string>
|
||||
<string name="custom_url_scheme">io.ionic.starter</string>
|
||||
</resources>
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/splashBackground</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_logo</item>
|
||||
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -15,5 +15,4 @@ ext {
|
||||
cordovaAndroidVersion = '14.0.1'
|
||||
rgcfaIncludeGoogle = true
|
||||
androidxCredentialsVersion = '1.3.0'
|
||||
javaVersion = JavaVersion.VERSION_17
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.habitmode.llemba.com',
|
||||
appName: 'HabitMode',
|
||||
appId: 'com.qoutelink.app',
|
||||
appName: 'Qoutelink',
|
||||
webDir: 'dist',
|
||||
plugins: {
|
||||
FirebaseAuthentication: {
|
||||
skipNativeAuth: false,
|
||||
providers: ["google.com", "apple.com"],
|
||||
providers: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,13 +55,6 @@ workflows:
|
||||
if [ -n "$CM_DEBUG_KEYSTORE" ]; then
|
||||
echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12
|
||||
fi
|
||||
- name: Check native credentials
|
||||
script: |
|
||||
if [ -n "$VITE_GOOGLE_MAPS_KEY" ]; then
|
||||
echo "VITE_GOOGLE_MAPS_KEY: set (length=${#VITE_GOOGLE_MAPS_KEY}, ends …${VITE_GOOGLE_MAPS_KEY: -4})"
|
||||
else
|
||||
echo "WARNING: VITE_GOOGLE_MAPS_KEY not set — Google Maps will not render"
|
||||
fi
|
||||
- name: Build debug APK
|
||||
script: |
|
||||
cd android
|
||||
@@ -75,21 +68,6 @@ workflows:
|
||||
else
|
||||
./gradlew assembleDebug --no-daemon
|
||||
fi
|
||||
- name: Verify manifest substitution
|
||||
script: |
|
||||
MANIFEST="android/app/build/intermediates/merged_manifests/debug/AndroidManifest.xml"
|
||||
if [ -f "$MANIFEST" ]; then
|
||||
VAL=$(grep -A1 'geo.API_KEY' "$MANIFEST" | grep -o 'android:value="[^"]*"' | cut -d'"' -f2)
|
||||
if [ -z "$VAL" ]; then
|
||||
echo "WARNING: geo.API_KEY meta-data not found in merged manifest"
|
||||
elif [ "$VAL" = '${googleMapsApiKey}' ]; then
|
||||
echo "WARNING: geo.API_KEY placeholder was not substituted — key missing from build env"
|
||||
else
|
||||
echo "geo.API_KEY substituted OK (ends …${VAL: -4})"
|
||||
fi
|
||||
else
|
||||
echo "Merged manifest not found at $MANIFEST"
|
||||
fi
|
||||
artifacts:
|
||||
- android/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
|
||||
@@ -299,7 +299,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -308,7 +308,7 @@
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.qoutelink.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -322,7 +322,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -330,7 +330,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.qoutelink.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<?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>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
<key>aps-environment</key>
|
||||
<string>production</string>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,7 +1,5 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
import FirebaseCore
|
||||
import FirebaseMessaging
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
@@ -9,40 +7,43 @@ 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 applicationWillResignActive(_ application: UIApplication) {}
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {}
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {}
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {}
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {}
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
// Called when the app was launched with a url. Feel free to add additional processing here,
|
||||
// but if you want the App API to support tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||
// Called when the app was launched with an activity, including Universal Links.
|
||||
// Feel free to add additional processing here, but if you want the App API to support
|
||||
// tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,122 @@
|
||||
{
|
||||
"images" : [
|
||||
"images": [
|
||||
{
|
||||
"filename" : "AppIcon-512@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"info": {
|
||||
"author": "appsuite",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 890 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 809 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
@@ -1,23 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
"images": [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-2.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732.png",
|
||||
"scale" : "3x"
|
||||
"idiom": "universal",
|
||||
"filename": "splash.png",
|
||||
"scale": "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
"info": {
|
||||
"author": "appsuite",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 480 KiB |
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>HabitMode</string>
|
||||
<string>Qoutelink</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -47,12 +47,18 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need access to your photo library so you can attach site photos and references to your requests.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need access to your camera so you can take site photos for your requests.</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.qoutelink.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>com.googleusercontent.apps.1097913965629-pf48952i0uv9shgvie3dq11s18sd0t2p</string>
|
||||
<string>com.qoutelink.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
@@ -18,12 +18,14 @@
|
||||
"@capacitor/haptics": "8.0.2",
|
||||
"@capacitor/ios": "8.3.4",
|
||||
"@capacitor/keyboard": "8.0.3",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"@capacitor/push-notifications": "^8.1.1",
|
||||
"@capacitor/status-bar": "8.0.2",
|
||||
"@ionic/pwa-elements": "^3.4.0",
|
||||
"@ionic/react": "^8.5.0",
|
||||
"@ionic/react-router": "^8.5.0",
|
||||
"@supabase/supabase-js": "^2.0.0",
|
||||
"@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",
|
||||
"firebase": "^11.0.0",
|
||||
"ionicons": "^7.4.0",
|
||||
"katex": "^0.16.0",
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route } from 'react-router-dom';
|
||||
import { IonApp, IonRouterOutlet } from '@ionic/react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { IonApp } from '@ionic/react';
|
||||
import { IonReactRouter } from '@ionic/react-router';
|
||||
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ProfileProvider, useProfile } from './contexts/ProfileContext';
|
||||
import AppRoutes from './AppRoutes';
|
||||
import { setStatusBarStyle, Style } from './utils/statusBar';
|
||||
|
||||
/* Core CSS required for Ionic components to work properly */
|
||||
import '@ionic/react/css/core.css';
|
||||
|
||||
/* Basic CSS for apps built with Ionic */
|
||||
import '@ionic/react/css/normalize.css';
|
||||
import '@ionic/react/css/structure.css';
|
||||
import '@ionic/react/css/typography.css';
|
||||
|
||||
/* Optional CSS utils that can be commented out */
|
||||
import '@ionic/react/css/padding.css';
|
||||
import '@ionic/react/css/float-elements.css';
|
||||
import '@ionic/react/css/text-alignment.css';
|
||||
@@ -14,27 +23,34 @@ import '@ionic/react/css/text-transformation.css';
|
||||
import '@ionic/react/css/flex-utils.css';
|
||||
import '@ionic/react/css/display.css';
|
||||
|
||||
/* Theme variables */
|
||||
import './theme/variables.css';
|
||||
|
||||
import Home from './pages/Home';
|
||||
import Account from './pages/Account';
|
||||
import Auth from './pages/Auth';
|
||||
import Stats from './pages/Stats';
|
||||
import HabitDetail from './pages/HabitDetail';
|
||||
const AppShell: React.FC = () => {
|
||||
const { themeMode } = useProfile();
|
||||
|
||||
const App = (): any => (
|
||||
<IonApp>
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle('dark', themeMode === 'dark');
|
||||
setStatusBarStyle(themeMode === 'dark' ? Style.Dark : Style.Light);
|
||||
}, [themeMode]);
|
||||
|
||||
return (
|
||||
<IonReactRouter>
|
||||
<IonRouterOutlet>
|
||||
<Route exact path="/auth" component={Auth} />
|
||||
<Route exact path="/home" component={Home} />
|
||||
<Route exact path="/stats" component={Stats} />
|
||||
<Route exact path="/habit/:id" component={HabitDetail} />
|
||||
<Route exact path="/account" component={Account} />
|
||||
<Route exact path="/" render={() => <Redirect to="/home" />} />
|
||||
</IonRouterOutlet>
|
||||
<AppRoutes />
|
||||
</IonReactRouter>
|
||||
</IonApp>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<IonApp>
|
||||
<AuthProvider>
|
||||
<ProfileProvider>
|
||||
<AppShell />
|
||||
</ProfileProvider>
|
||||
</AuthProvider>
|
||||
</IonApp>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { Route, Redirect } from 'react-router-dom';
|
||||
import {
|
||||
IonRouterOutlet,
|
||||
IonTabs,
|
||||
IonTabBar,
|
||||
IonTabButton,
|
||||
IonIcon,
|
||||
IonLabel,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
homeOutline,
|
||||
documentTextOutline,
|
||||
personOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { useAuth } from './contexts/AuthContext';
|
||||
|
||||
import Auth from './pages/Auth';
|
||||
import VerifyEmail from './pages/VerifyEmail';
|
||||
import ForgotPassword from './pages/ForgotPassword';
|
||||
import VerifyReset from './pages/VerifyReset';
|
||||
import SetupProfile from './pages/SetupProfile';
|
||||
import Home from './pages/Home';
|
||||
import BuyerRequests from './pages/BuyerRequests';
|
||||
import NewRfq from './pages/NewRfq';
|
||||
import RfqDetail from './pages/RfqDetail';
|
||||
import SupplierQuoteForm from './pages/SupplierQuoteForm';
|
||||
import BuyerQuoteList from './pages/BuyerQuoteList';
|
||||
import Profile from './pages/Profile';
|
||||
import EditProfile from './pages/EditProfile';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
|
||||
const AppRoutes: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<IonRouterOutlet>
|
||||
{/* Auth routes */}
|
||||
<Route path="/auth" component={Auth} exact />
|
||||
<Route path="/verify-email" component={VerifyEmail} exact />
|
||||
<Route path="/forgot-password" component={ForgotPassword} exact />
|
||||
<Route path="/verify-reset" component={VerifyReset} exact />
|
||||
|
||||
{/* Setup Profile (guarded manually by user presence, not ProtectedRoute to avoid loop) */}
|
||||
<Route
|
||||
path="/setup-profile"
|
||||
exact
|
||||
render={() => (!user ? <Redirect to="/auth" /> : <SetupProfile />)}
|
||||
/>
|
||||
|
||||
{/* Pushed routes without tab bar */}
|
||||
<ProtectedRoute path="/rfq/new" component={NewRfq} exact />
|
||||
<ProtectedRoute
|
||||
path="/rfq/:id([0-9a-fA-F-]{36})"
|
||||
component={RfqDetail}
|
||||
exact
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/quote/:id([0-9a-fA-F-]{36})"
|
||||
component={SupplierQuoteForm}
|
||||
exact
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/quote-list/:id([0-9a-fA-F-]{36})"
|
||||
component={BuyerQuoteList}
|
||||
exact
|
||||
/>
|
||||
<ProtectedRoute path="/profile/edit" component={EditProfile} exact />
|
||||
|
||||
{/* Tabs */}
|
||||
<ProtectedRoute
|
||||
path={['/home', '/requests', '/profile']}
|
||||
component={() => (
|
||||
<IonTabs>
|
||||
<IonRouterOutlet>
|
||||
<Route path="/home" component={Home} exact />
|
||||
<Route path="/requests" component={BuyerRequests} exact />
|
||||
<Route path="/profile" component={Profile} exact />
|
||||
</IonRouterOutlet>
|
||||
|
||||
<IonTabBar slot="bottom">
|
||||
<IonTabButton tab="home" href="/home">
|
||||
<IonIcon icon={homeOutline} />
|
||||
<IonLabel>Home</IonLabel>
|
||||
</IonTabButton>
|
||||
<IonTabButton tab="requests" href="/requests">
|
||||
<IonIcon icon={documentTextOutline} />
|
||||
<IonLabel>Requests</IonLabel>
|
||||
</IonTabButton>
|
||||
<IonTabButton tab="profile" href="/profile">
|
||||
<IonIcon icon={personOutline} />
|
||||
<IonLabel>Profile</IonLabel>
|
||||
</IonTabButton>
|
||||
</IonTabBar>
|
||||
</IonTabs>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Default Redirect */}
|
||||
<Route exact path="/" render={() => <Redirect to="/home" />} />
|
||||
</IonRouterOutlet>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppRoutes;
|
||||
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 771 KiB |
|
Before Width: | Height: | Size: 752 KiB |
@@ -1,77 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonProgressBar } from '@ionic/react';
|
||||
|
||||
interface CustomProgressBarProps {
|
||||
value: number; // 0 to 1
|
||||
label?: string;
|
||||
statusText?: string;
|
||||
color?: string; // Hex, rgb, or css variable
|
||||
showPercent?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const CustomProgressBar: React.FC<CustomProgressBarProps> = ({
|
||||
value,
|
||||
label,
|
||||
statusText,
|
||||
color = 'var(--ion-color-primary)',
|
||||
showPercent = true,
|
||||
style,
|
||||
}) => {
|
||||
const percentage = Math.round(Math.min(Math.max(value, 0), 1) * 100);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', ...style }}>
|
||||
{/* Label and Status */}
|
||||
{(label || statusText || showPercent) && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
fontSize: '0.8rem',
|
||||
marginBottom: '6px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--ion-color-medium)' }}>
|
||||
{label}{' '}
|
||||
{statusText && (
|
||||
<span style={{ fontWeight: 'normal', opacity: 0.85 }}>
|
||||
• {statusText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showPercent && (
|
||||
<span
|
||||
style={{
|
||||
color: color,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
fontWeight: '700',
|
||||
}}
|
||||
>
|
||||
{percentage}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Track */}
|
||||
<IonProgressBar
|
||||
value={value}
|
||||
style={
|
||||
{
|
||||
height: '6px',
|
||||
'--background': 'rgba(148, 163, 184, 0.1)',
|
||||
'--progress-background': color,
|
||||
borderRadius: '10px',
|
||||
transition: 'value 0.3s ease',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomProgressBar;
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
|
||||
interface DashboardMetricCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
icon: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const DashboardMetricCard: React.FC<DashboardMetricCardProps> = ({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
icon,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: '24px',
|
||||
padding: '20px',
|
||||
flex: '1',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '12px',
|
||||
background: 'var(--color-brand-medium)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={icon}
|
||||
style={{ color: 'var(--color-brand)', fontSize: '18px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: '1',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
{hint}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardMetricCard;
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonButton, IonIcon } from '@ionic/react';
|
||||
import { sparklesOutline } from 'ionicons/icons';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
message: string;
|
||||
actionLabel: string;
|
||||
onAction: () => void;
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
title,
|
||||
message,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}) => {
|
||||
return (
|
||||
<div className="empty-state-card home-empty-state-card">
|
||||
<div className="empty-state-icon">
|
||||
<IonIcon icon={sparklesOutline} />
|
||||
</div>
|
||||
<h2>{title}</h2>
|
||||
<p>{message}</p>
|
||||
<IonButton className="home-empty-state-button" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</IonButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyState;
|
||||
@@ -1,108 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonButton, IonIcon, IonSpinner } from '@ionic/react';
|
||||
import {
|
||||
checkmarkOutline,
|
||||
ellipseOutline,
|
||||
flameOutline,
|
||||
trashOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import type { Tables } from '../database.types';
|
||||
|
||||
interface HabitCardProps {
|
||||
habit: Tables<'habits'>;
|
||||
completedToday: boolean;
|
||||
currentStreak: number;
|
||||
completionRate: number;
|
||||
onToggle: () => void;
|
||||
onDelete: () => void;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
const HabitCard: React.FC<HabitCardProps> = ({
|
||||
habit,
|
||||
completedToday,
|
||||
currentStreak,
|
||||
completionRate,
|
||||
onToggle,
|
||||
onDelete,
|
||||
busy,
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`habit-card habit-card--editorial ${completedToday ? 'is-complete' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => history.push(`/habit/${habit.id}`)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
history.push(`/habit/${habit.id}`);
|
||||
}
|
||||
}}
|
||||
aria-label={`View details for ${habit.name}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`habit-check-button ${completedToday ? 'is-complete' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
disabled={busy}
|
||||
aria-label={
|
||||
completedToday
|
||||
? `Mark ${habit.name} as not done`
|
||||
: `Mark ${habit.name} as done`
|
||||
}
|
||||
>
|
||||
{busy ? (
|
||||
<IonSpinner name="crescent" />
|
||||
) : completedToday ? (
|
||||
<IonIcon icon={checkmarkOutline} />
|
||||
) : (
|
||||
<IonIcon icon={ellipseOutline} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="habit-card-main">
|
||||
<div className="habit-copy">
|
||||
<div className="habit-copy-topline">
|
||||
<h3>{habit.name}</h3>
|
||||
<span
|
||||
className="habit-color-pill"
|
||||
style={{ background: habit.color }}
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
{habit.target_description ||
|
||||
'Show up today and keep the chain alive.'}
|
||||
</p>
|
||||
<div className="habit-metrics-row">
|
||||
<span>
|
||||
<IonIcon icon={flameOutline} /> {currentStreak} streak
|
||||
</span>
|
||||
<span>{completionRate}% this week</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IonButton
|
||||
className="habit-delete-button"
|
||||
fill="clear"
|
||||
color="medium"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
<IonIcon icon={trashOutline} />
|
||||
</IonButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HabitCard;
|
||||
@@ -1,153 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonModal,
|
||||
IonTextarea,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from '@ionic/react';
|
||||
|
||||
interface HabitFormModalProps {
|
||||
isOpen: boolean;
|
||||
onDismiss: () => void;
|
||||
onSubmit: (values: {
|
||||
name: string;
|
||||
targetDescription: string;
|
||||
color: string;
|
||||
}) => Promise<void>;
|
||||
initialValues?: {
|
||||
name: string;
|
||||
targetDescription: string;
|
||||
color: string;
|
||||
};
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#7c3aed',
|
||||
'#0f766e',
|
||||
'#ea580c',
|
||||
'#dc2626',
|
||||
'#2563eb',
|
||||
'#059669',
|
||||
];
|
||||
|
||||
const HabitFormModal: React.FC<HabitFormModalProps> = ({
|
||||
isOpen,
|
||||
onDismiss,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
}) => {
|
||||
const isEditing = !!initialValues;
|
||||
const [name, setName] = useState(initialValues?.name ?? '');
|
||||
const [targetDescription, setTargetDescription] = useState(
|
||||
initialValues?.targetDescription ?? ''
|
||||
);
|
||||
const [color, setColor] = useState(initialValues?.color ?? COLORS[0]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() => name.trim().length > 0 && name.trim().length <= 80,
|
||||
[name]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName(initialValues?.name ?? '');
|
||||
setTargetDescription(initialValues?.targetDescription ?? '');
|
||||
setColor(initialValues?.color ?? COLORS[0]);
|
||||
setError('');
|
||||
}
|
||||
}, [isOpen, initialValues]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
name: name.trim(),
|
||||
targetDescription: targetDescription.trim(),
|
||||
color,
|
||||
});
|
||||
setName('');
|
||||
setTargetDescription('');
|
||||
setColor(COLORS[0]);
|
||||
onDismiss();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save habit.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonModal isOpen={isOpen} onDidDismiss={onDismiss}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonTitle>{isEditing ? 'Edit Habit' : 'New Habit'}</IonTitle>
|
||||
<IonButtons slot="end">
|
||||
<IonButton onClick={onDismiss}>Close</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent className="ion-padding">
|
||||
<div className="habit-form-grid">
|
||||
<IonItem lines="none" className="auth-input-item">
|
||||
<IonInput
|
||||
label="Habit name"
|
||||
labelPlacement="stacked"
|
||||
value={name}
|
||||
maxlength={80}
|
||||
placeholder="Drink water"
|
||||
onIonInput={(e) => setName(e.detail.value ?? '')}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<IonItem lines="none" className="auth-input-item">
|
||||
<IonTextarea
|
||||
label="Target or note"
|
||||
labelPlacement="stacked"
|
||||
value={targetDescription}
|
||||
placeholder="8 glasses, 20 minutes, 30 pushups..."
|
||||
autoGrow
|
||||
onIonInput={(e) => setTargetDescription(e.detail.value ?? '')}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<div>
|
||||
<IonLabel className="color-label">Color</IonLabel>
|
||||
<div className="habit-color-row">
|
||||
{COLORS.map((swatch) => (
|
||||
<button
|
||||
key={swatch}
|
||||
type="button"
|
||||
className={`habit-color-dot ${color === swatch ? 'selected' : ''}`}
|
||||
style={{ background: swatch }}
|
||||
onClick={() => setColor(swatch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="auth-feedback error-text">{error}</p> : null}
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || saving}
|
||||
>
|
||||
{saving ? 'Saving…' : isEditing ? 'Save Changes' : 'Create Habit'}
|
||||
</IonButton>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default HabitFormModal;
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { IonInput } from '@ionic/react';
|
||||
|
||||
interface OtpInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const OtpInput: React.FC<OtpInputProps> = ({ value, onChange, disabled }) => {
|
||||
const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
// Convert current value string into an array of 6 chars
|
||||
const digits = value.split('').concat(Array(6).fill('')).slice(0, 6);
|
||||
|
||||
const handleChange = (e: any, index: number) => {
|
||||
let val = e.target.value;
|
||||
// Allow only numeric
|
||||
if (!/^[0-9]*$/.test(val)) return;
|
||||
|
||||
// Grab the last character if they typed multiple
|
||||
val = val.substring(val.length - 1);
|
||||
|
||||
const newDigits = [...digits];
|
||||
newDigits[index] = val;
|
||||
onChange(newDigits.join(''));
|
||||
|
||||
// Move to next input if filled
|
||||
if (val !== '' && index < 5) {
|
||||
const nextInput = inputsRef.current[index + 1];
|
||||
if (nextInput) nextInput.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (
|
||||
e: React.KeyboardEvent<HTMLInputElement>,
|
||||
index: number
|
||||
) => {
|
||||
if (e.key === 'Backspace' && !digits[index] && index > 0) {
|
||||
// Move to previous input on backspace if current is empty
|
||||
const prevInput = inputsRef.current[index - 1];
|
||||
if (prevInput) {
|
||||
prevInput.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
|
||||
{digits.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(el) => {
|
||||
inputsRef.current[index] = el;
|
||||
}}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={2}
|
||||
value={digit}
|
||||
onChange={(e) => handleChange(e, index)}
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '48px',
|
||||
height: '52px',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
background: '#f4f7f4',
|
||||
color: '#0f1720',
|
||||
textAlign: 'center',
|
||||
fontSize: '24px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OtpInput;
|
||||
@@ -1,69 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonButtons,
|
||||
IonBackButton,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
showBackButton?: boolean;
|
||||
defaultBackHref?: string;
|
||||
endActions?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
toolbarStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
title,
|
||||
showBackButton = false,
|
||||
defaultBackHref = '/home',
|
||||
endActions,
|
||||
style,
|
||||
toolbarStyle,
|
||||
}) => {
|
||||
// Use Ionic lifecycle hook to set status bar style automatically for pages using PageHeader
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark); // Dark text/icons for light-colored headers
|
||||
});
|
||||
|
||||
return (
|
||||
<IonHeader className="ion-no-border" style={style}>
|
||||
<IonToolbar
|
||||
style={
|
||||
{
|
||||
'--background': '#ffffff',
|
||||
'--color': 'var(--ion-color-dark)',
|
||||
'--border-width': '0 0 1px 0',
|
||||
'--border-color': 'rgba(0, 0, 0, 0.05)',
|
||||
...toolbarStyle,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{showBackButton && (
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref={defaultBackHref} />
|
||||
</IonButtons>
|
||||
)}
|
||||
|
||||
<IonTitle
|
||||
style={{
|
||||
fontWeight: '800',
|
||||
fontSize: '1.1rem',
|
||||
letterSpacing: '-0.3px',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</IonTitle>
|
||||
|
||||
{endActions && <IonButtons slot="end">{endActions}</IonButtons>}
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageHeader;
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
|
||||
interface ProfileInfoRowProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const ProfileInfoRow: React.FC<ProfileInfoRowProps> = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: '64px',
|
||||
padding: '10px 20px',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '14px',
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '12px',
|
||||
background: 'var(--color-surface-raised)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={icon}
|
||||
style={{ color: 'var(--color-brand)', fontSize: '20px' }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{value && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileInfoRow;
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Route, Redirect, RouteProps } from 'react-router-dom';
|
||||
import { IonSpinner } from '@ionic/react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useProfile } from '../contexts/ProfileContext';
|
||||
import { useProfileLoader } from '../hooks/useProfileLoader';
|
||||
|
||||
interface ProtectedRouteProps extends RouteProps {
|
||||
component: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
component: Component,
|
||||
...rest
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { status } = useProfile();
|
||||
const { loadProfileAndNavigate } = useProfileLoader();
|
||||
|
||||
useEffect(() => {
|
||||
// If user is logged in but profile is still loading (e.g. cold start direct link)
|
||||
if (user && status === 'loading') {
|
||||
loadProfileAndNavigate();
|
||||
}
|
||||
}, [user, status, loadProfileAndNavigate]);
|
||||
|
||||
return (
|
||||
<Route
|
||||
{...rest}
|
||||
render={(props) => {
|
||||
if (!user) {
|
||||
return <Redirect to="/auth" />;
|
||||
}
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<IonSpinner name="crescent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status === 'missing') {
|
||||
return <Redirect to="/setup-profile" />;
|
||||
}
|
||||
return <Component {...props} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
|
||||
interface QuotationCardProps {
|
||||
supplierName: string;
|
||||
priceLabel: string;
|
||||
deliveryOption: string;
|
||||
status: string;
|
||||
onAccept: () => void;
|
||||
onDecline: () => void;
|
||||
}
|
||||
|
||||
const QuotationCard: React.FC<QuotationCardProps> = ({
|
||||
supplierName,
|
||||
priceLabel,
|
||||
deliveryOption,
|
||||
status,
|
||||
onAccept,
|
||||
onDecline,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: '24px',
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{supplierName}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: '30px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-brand-strong)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
>
|
||||
{priceLabel}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginBottom: '18px',
|
||||
}}
|
||||
>
|
||||
{deliveryOption}
|
||||
</div>
|
||||
|
||||
{status === 'submitted' ? (
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={onAccept}
|
||||
style={{
|
||||
background: 'var(--color-brand)',
|
||||
color: '#ffffff',
|
||||
borderRadius: '999px',
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
flex: 1,
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
onClick={onDecline}
|
||||
style={{
|
||||
background: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-brand)',
|
||||
borderRadius: '999px',
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
flex: 1,
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
background:
|
||||
status === 'accepted'
|
||||
? 'var(--color-success-soft)'
|
||||
: 'rgba(23,33,43,0.10)',
|
||||
color:
|
||||
status === 'accepted' ? 'var(--color-success-text)' : '#4b5563',
|
||||
borderRadius: '999px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotationCard;
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
|
||||
interface QuoteSummaryRowProps {
|
||||
supplierName: string;
|
||||
priceLabel: string;
|
||||
deliveryOption: string;
|
||||
status: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const QuoteSummaryRow: React.FC<QuoteSummaryRowProps> = ({
|
||||
supplierName,
|
||||
priceLabel,
|
||||
deliveryOption,
|
||||
status,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
padding: '12px 0',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
{supplierName}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-brand-strong)',
|
||||
}}
|
||||
>
|
||||
{priceLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{deliveryOption}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color:
|
||||
status === 'accepted'
|
||||
? 'var(--color-success-text)'
|
||||
: 'var(--color-text-secondary)',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuoteSummaryRow;
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
|
||||
interface RecentActivityCardProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
meta: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const RecentActivityCard: React.FC<RecentActivityCardProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
meta,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: 'var(--color-shell)',
|
||||
borderRadius: '38px',
|
||||
padding: '5px',
|
||||
margin: '0 20px 12px',
|
||||
boxShadow: 'var(--color-shadow-lg)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: '32px',
|
||||
padding: '18px 20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
boxShadow: 'var(--color-shadow-sm)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-brand-strong)',
|
||||
textTransform: 'capitalize',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{meta}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecentActivityCard;
|
||||
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
|
||||
interface RequestListCardProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
referenceNumber: string;
|
||||
status: string;
|
||||
trailingText?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const RequestListCard: React.FC<RequestListCardProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
referenceNumber,
|
||||
status,
|
||||
trailingText,
|
||||
onClick,
|
||||
}) => {
|
||||
let statusBg = 'rgba(23,33,43,0.10)';
|
||||
let statusColor = '#4b5563';
|
||||
|
||||
if (status === 'open') {
|
||||
statusBg = 'var(--color-success-soft)';
|
||||
statusColor = 'var(--color-success-text)';
|
||||
} else if (status === 'quoted') {
|
||||
statusBg = 'var(--color-warning-soft)';
|
||||
statusColor = 'var(--color-warning-text)';
|
||||
} else if (status === 'closed') {
|
||||
statusBg = 'rgba(23,33,43,0.10)';
|
||||
statusColor = '#4b5563';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: '24px',
|
||||
padding: '20px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '14px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
</div>
|
||||
{trailingText && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-brand-strong)',
|
||||
marginLeft: '12px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{trailingText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-accent-soft)',
|
||||
color: 'var(--color-warning-text)',
|
||||
borderRadius: '999px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
}}
|
||||
>
|
||||
{referenceNumber}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
background: statusBg,
|
||||
color: statusColor,
|
||||
borderRadius: '999px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestListCard;
|
||||
@@ -1,80 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
|
||||
interface RevisionInstrumentCardProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: string;
|
||||
iconColor: string;
|
||||
iconBgColor: string;
|
||||
onClick: () => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const RevisionInstrumentCard: React.FC<RevisionInstrumentCardProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
iconColor,
|
||||
iconBgColor,
|
||||
onClick,
|
||||
style,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
border: 'none',
|
||||
borderRadius: '14px',
|
||||
padding: '14px 10px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: 'none',
|
||||
transition: 'transform 0.15s ease',
|
||||
...style,
|
||||
}}
|
||||
className="ion-activatable"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '10px',
|
||||
background: iconBgColor,
|
||||
color: iconColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 8px auto',
|
||||
}}
|
||||
>
|
||||
<IonIcon icon={icon} style={{ fontSize: '18px' }} />
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: '700',
|
||||
color: 'var(--ion-color-dark)',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.65rem',
|
||||
color: 'var(--ion-color-medium)',
|
||||
display: 'block',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevisionInstrumentCard;
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonProgressBar } from '@ionic/react';
|
||||
|
||||
interface StatsHabitCardProps {
|
||||
name: string;
|
||||
color: string;
|
||||
completedCount: number;
|
||||
totalDays: number;
|
||||
streak: number;
|
||||
}
|
||||
|
||||
const StatsHabitCard: React.FC<StatsHabitCardProps> = ({
|
||||
name,
|
||||
color,
|
||||
completedCount,
|
||||
totalDays,
|
||||
streak,
|
||||
}) => {
|
||||
const ratio = totalDays > 0 ? completedCount / totalDays : 0;
|
||||
const percentage = Math.round(ratio * 100);
|
||||
|
||||
return (
|
||||
<div className="stats-card">
|
||||
<div className="stats-card-header">
|
||||
<div>
|
||||
<h3>{name}</h3>
|
||||
<p>
|
||||
{completedCount} of {totalDays} days completed
|
||||
</p>
|
||||
</div>
|
||||
<div className="stats-streak-badge" style={{ color }}>
|
||||
{streak}🔥
|
||||
</div>
|
||||
</div>
|
||||
<IonProgressBar
|
||||
value={ratio}
|
||||
style={{ '--progress-background': color } as React.CSSProperties}
|
||||
/>
|
||||
<div className="stats-card-footer">
|
||||
<span>{percentage}% consistency</span>
|
||||
<span>Current streak {streak}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsHabitCard;
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
import { checkmarkCircle, ellipseOutline } from 'ionicons/icons';
|
||||
|
||||
export interface SupplierOption {
|
||||
id: string;
|
||||
full_name: string;
|
||||
company_name: string | null;
|
||||
city: string | null;
|
||||
}
|
||||
|
||||
interface SupplierPickerRowProps {
|
||||
supplier: SupplierOption;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const SupplierPickerRow: React.FC<SupplierPickerRowProps> = ({
|
||||
supplier,
|
||||
selected,
|
||||
onToggle,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '12px 0',
|
||||
borderBottom: '1px solid #edf4ee',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={selected ? checkmarkCircle : ellipseOutline}
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
color: selected ? '#166534' : '#8a978f',
|
||||
marginRight: '12px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '15px', fontWeight: '600', color: '#0f1720' }}>
|
||||
{supplier.company_name || supplier.full_name}
|
||||
</span>
|
||||
<span style={{ fontSize: '13px', fontWeight: '400', color: '#6c7a71' }}>
|
||||
{supplier.company_name ? supplier.full_name : 'Independent'}
|
||||
{supplier.city ? ` • ${supplier.city}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupplierPickerRow;
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { supabase } from '../supabase';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
signOut: async () => {},
|
||||
});
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
setUser(data.session?.user ?? null);
|
||||
setInitializing(false);
|
||||
});
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setUser(session?.user ?? null);
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
const signOut = async () => {
|
||||
await supabase.auth.signOut();
|
||||
};
|
||||
|
||||
if (initializing) {
|
||||
return null; // Or a simple spinner if preferred, but usually handled nicely
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, signOut }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { Database } from '../database.types';
|
||||
|
||||
export type Profile = Database['public']['Tables']['profiles']['Row'];
|
||||
export type ThemeMode = 'light' | 'dark';
|
||||
|
||||
interface ProfileContextType {
|
||||
profile: Profile | null;
|
||||
status: 'loading' | 'loaded' | 'missing';
|
||||
themeMode: ThemeMode;
|
||||
setProfile: (profile: Profile | null) => void;
|
||||
setStatus: (status: 'loading' | 'loaded' | 'missing') => void;
|
||||
setThemeMode: (mode: ThemeMode) => Promise<void>;
|
||||
clearProfile: () => void;
|
||||
}
|
||||
|
||||
const ProfileContext = createContext<ProfileContextType>({
|
||||
profile: null,
|
||||
status: 'loading',
|
||||
themeMode: 'light',
|
||||
setProfile: () => {},
|
||||
setStatus: () => {},
|
||||
setThemeMode: async () => {},
|
||||
clearProfile: () => {},
|
||||
});
|
||||
|
||||
export const useProfile = () => useContext(ProfileContext);
|
||||
|
||||
export const ProfileProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [status, setStatus] = useState<'loading' | 'loaded' | 'missing'>(
|
||||
'loading'
|
||||
);
|
||||
const [themeModeState, setThemeModeState] = useState<ThemeMode>('light');
|
||||
|
||||
useEffect(() => {
|
||||
const loadThemeMode = async () => {
|
||||
const { value } = await Preferences.get({ key: 'theme_mode' });
|
||||
const mode = value === 'dark' ? 'dark' : 'light';
|
||||
setThemeModeState(mode);
|
||||
};
|
||||
|
||||
loadThemeMode();
|
||||
}, []);
|
||||
|
||||
const setThemeMode = async (mode: ThemeMode) => {
|
||||
setThemeModeState(mode);
|
||||
await Preferences.set({ key: 'theme_mode', value: mode });
|
||||
};
|
||||
|
||||
const clearProfile = () => {
|
||||
setProfile(null);
|
||||
setStatus('loading');
|
||||
};
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
profile,
|
||||
status,
|
||||
themeMode: themeModeState,
|
||||
setProfile,
|
||||
setStatus,
|
||||
setThemeMode,
|
||||
clearProfile,
|
||||
}),
|
||||
[profile, status, themeModeState]
|
||||
);
|
||||
|
||||
return (
|
||||
<ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -14,124 +14,204 @@ export type Database = {
|
||||
}
|
||||
public: {
|
||||
Tables: {
|
||||
habit_completions: {
|
||||
profiles: {
|
||||
Row: {
|
||||
completed_on: string
|
||||
avatar_url: string | null
|
||||
city: string | null
|
||||
company_name: string | null
|
||||
created_at: string
|
||||
habit_id: string
|
||||
full_name: string
|
||||
id: string
|
||||
user_id: string
|
||||
phone: string | null
|
||||
role: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
completed_on: string
|
||||
avatar_url?: string | null
|
||||
city?: string | null
|
||||
company_name?: string | null
|
||||
created_at?: string
|
||||
habit_id: string
|
||||
id?: string
|
||||
user_id: string
|
||||
full_name: string
|
||||
id: string
|
||||
phone?: string | null
|
||||
role: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
completed_on?: string
|
||||
avatar_url?: string | null
|
||||
city?: string | null
|
||||
company_name?: string | null
|
||||
created_at?: string
|
||||
habit_id?: string
|
||||
full_name?: string
|
||||
id?: string
|
||||
user_id?: string
|
||||
phone?: string | null
|
||||
role?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
quotations: {
|
||||
Row: {
|
||||
created_at: string
|
||||
currency_code: string
|
||||
delivery_option: string
|
||||
id: string
|
||||
note: string | null
|
||||
price_amount: number
|
||||
rfq_id: string
|
||||
status: string
|
||||
supplier_id: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
currency_code?: string
|
||||
delivery_option: string
|
||||
id?: string
|
||||
note?: string | null
|
||||
price_amount: number
|
||||
rfq_id: string
|
||||
status?: string
|
||||
supplier_id: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
currency_code?: string
|
||||
delivery_option?: string
|
||||
id?: string
|
||||
note?: string | null
|
||||
price_amount?: number
|
||||
rfq_id?: string
|
||||
status?: string
|
||||
supplier_id?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "habit_completions_habit_id_fkey"
|
||||
columns: ["habit_id"]
|
||||
foreignKeyName: "quotations_rfq_id_fkey"
|
||||
columns: ["rfq_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "habits"
|
||||
referencedRelation: "rfqs"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "quotations_supplier_id_fkey"
|
||||
columns: ["supplier_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
habits: {
|
||||
rfq_images: {
|
||||
Row: {
|
||||
color: string
|
||||
created_at: string
|
||||
fcm_token: string | null
|
||||
id: string
|
||||
name: string
|
||||
target_description: string | null
|
||||
user_id: string
|
||||
owner_id: string
|
||||
rfq_id: string
|
||||
storage_path: string
|
||||
}
|
||||
Insert: {
|
||||
color?: string
|
||||
created_at?: string
|
||||
fcm_token?: string | null
|
||||
id?: string
|
||||
name: string
|
||||
target_description?: string | null
|
||||
user_id: string
|
||||
owner_id: string
|
||||
rfq_id: string
|
||||
storage_path: string
|
||||
}
|
||||
Update: {
|
||||
color?: string
|
||||
created_at?: string
|
||||
fcm_token?: string | null
|
||||
id?: string
|
||||
name?: string
|
||||
target_description?: string | null
|
||||
user_id?: string
|
||||
owner_id?: string
|
||||
rfq_id?: string
|
||||
storage_path?: string
|
||||
}
|
||||
Relationships: []
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "rfq_images_rfq_id_fkey"
|
||||
columns: ["rfq_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "rfqs"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
notes: {
|
||||
rfq_recipients: {
|
||||
Row: {
|
||||
content: string
|
||||
created_at: string
|
||||
id: string
|
||||
is_favorite: boolean
|
||||
title: string
|
||||
user_id: string
|
||||
rfq_id: string
|
||||
supplier_id: string
|
||||
}
|
||||
Insert: {
|
||||
content: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
is_favorite?: boolean
|
||||
title: string
|
||||
user_id?: string
|
||||
rfq_id: string
|
||||
supplier_id: string
|
||||
}
|
||||
Update: {
|
||||
content?: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
is_favorite?: boolean
|
||||
title?: string
|
||||
user_id?: string
|
||||
rfq_id?: string
|
||||
supplier_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "rfq_recipients_rfq_id_fkey"
|
||||
columns: ["rfq_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "rfqs"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "rfq_recipients_supplier_id_fkey"
|
||||
columns: ["supplier_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
todos: {
|
||||
rfqs: {
|
||||
Row: {
|
||||
buyer_id: string
|
||||
created_at: string
|
||||
description: string | null
|
||||
due_date: string | null
|
||||
id: string
|
||||
is_completed: boolean
|
||||
reference_number: string
|
||||
request_text: string
|
||||
status: string
|
||||
title: string
|
||||
user_id: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
buyer_id: string
|
||||
created_at?: string
|
||||
description?: string | null
|
||||
due_date?: string | null
|
||||
id?: string
|
||||
is_completed?: boolean
|
||||
reference_number: string
|
||||
request_text: string
|
||||
status?: string
|
||||
title: string
|
||||
user_id?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
buyer_id?: string
|
||||
created_at?: string
|
||||
description?: string | null
|
||||
due_date?: string | null
|
||||
id?: string
|
||||
is_completed?: boolean
|
||||
reference_number?: string
|
||||
request_text?: string
|
||||
status?: string
|
||||
title?: string
|
||||
user_id?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "rfqs_buyer_id_fkey"
|
||||
columns: ["buyer_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useProfile, Profile } from '../contexts/ProfileContext';
|
||||
|
||||
export const useProfileLoader = () => {
|
||||
const history = useHistory();
|
||||
const { user } = useAuth();
|
||||
const { setProfile, setStatus } = useProfile();
|
||||
|
||||
const loadProfileAndNavigate = async (
|
||||
setOverlayVisible?: (v: boolean) => void,
|
||||
setOverlayError?: (v: boolean) => void
|
||||
) => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
// 1. Check cache
|
||||
const { value } = await Preferences.get({ key: `profile_${user.id}` });
|
||||
if (value) {
|
||||
const cachedProfile = JSON.parse(value) as Profile;
|
||||
setProfile(cachedProfile);
|
||||
setStatus('loaded');
|
||||
history.replace('/home');
|
||||
|
||||
// Silent refresh
|
||||
const { data } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
if (data) {
|
||||
setProfile(data);
|
||||
await Preferences.set({
|
||||
key: `profile_${user.id}`,
|
||||
value: JSON.stringify(data),
|
||||
});
|
||||
} else {
|
||||
// If silent refresh finds no row, they somehow lost their profile (or deleted it)
|
||||
// We can handle this by redirecting them next time, or immediately
|
||||
setStatus('missing');
|
||||
history.replace('/setup-profile');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Cache miss, fetch from remote
|
||||
if (setOverlayVisible) setOverlayVisible(true);
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
// PGRST116 means no rows found
|
||||
console.error('Error fetching profile:', error);
|
||||
if (setOverlayError) setOverlayError(true);
|
||||
return; // Keep overlay visible so they see the error or can retry
|
||||
}
|
||||
|
||||
if (data) {
|
||||
// Row found
|
||||
setProfile(data);
|
||||
setStatus('loaded');
|
||||
await Preferences.set({
|
||||
key: `profile_${user.id}`,
|
||||
value: JSON.stringify(data),
|
||||
});
|
||||
if (setOverlayVisible) setOverlayVisible(false);
|
||||
history.replace('/home');
|
||||
} else {
|
||||
// No row found
|
||||
setStatus('missing');
|
||||
if (setOverlayVisible) setOverlayVisible(false);
|
||||
history.replace('/setup-profile');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Unexpected error loading profile:', err);
|
||||
if (setOverlayError) setOverlayError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return { loadProfileAndNavigate };
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { Capacitor } from '@capacitor/core';
|
||||
import App from './App';
|
||||
|
||||
// ── Ionic initialisation ──────────────────────────────────────────────────────
|
||||
// ?mode=md|ios overrides (used by AppSuite preview); otherwise follow the platform.
|
||||
// ?mode=md|ios overrides (used by Appcakes preview); otherwise follow the platform.
|
||||
const _urlMode = new URLSearchParams(window.location.search).get('mode');
|
||||
const _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 appcakes.qqura.com).
|
||||
// (parent at localhost:3000) and production (parent at studio.appcakes.dev).
|
||||
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 AppSuite parent frame so the AI can test auth-protected
|
||||
// Post auth session to the Appcakes parent frame so the AI can test auth-protected
|
||||
// Edge Functions. Uses import.meta.glob so Vite never errors if supabase.ts is absent.
|
||||
(async () => {
|
||||
const mods = import.meta.glob('./supabase.ts', { eager: false });
|
||||
@@ -73,12 +73,17 @@ if (import.meta.hot) {
|
||||
})();
|
||||
|
||||
// ── Runtime error reporting ───────────────────────────────────────────────────
|
||||
// Forward uncaught errors to the AppSuite dev server so the AI can read them.
|
||||
// Forward uncaught errors to the Appcakes dev server so the AI can read them.
|
||||
// Extract projectId from the preview URL (/preview/{projectId}/) so errors are
|
||||
// scoped per-project on the server rather than mixed into a global queue.
|
||||
const _previewMatch = window.location.pathname.match(/\/preview\/([^/]+)/);
|
||||
const _projectId = _previewMatch?.[1] ?? null;
|
||||
|
||||
function reportError(message: string, stack?: string) {
|
||||
fetch(`${studioOrigin}/api/agent/runtime-error`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, stack }),
|
||||
body: JSON.stringify({ message, stack, projectId: _projectId }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
IonText,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
waterOutline,
|
||||
fitnessOutline,
|
||||
shieldCheckmarkOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Redirect as RedirectRR, useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import {
|
||||
Style,
|
||||
setStatusBarBackground,
|
||||
setStatusBarStyle,
|
||||
} from '../utils/statusBar';
|
||||
import { useIonViewWillEnter } from '@ionic/react';
|
||||
import runnerDrinkingWaterImage from '../assets/runner-drinking-water-photorealistic.png';
|
||||
|
||||
const RedirectAny = RedirectRR as any;
|
||||
|
||||
const Account: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
const [email, setEmail] = useState<string | null>(null);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [signOutError, setSignOutError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
if (!mounted) return;
|
||||
setEmail(data.session?.user.email ?? null);
|
||||
setSessionChecked(true);
|
||||
});
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
if (!mounted) return;
|
||||
setEmail(session?.user.email ?? null);
|
||||
setSessionChecked(true);
|
||||
|
||||
if (!session) {
|
||||
history.replace('/auth');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [history]);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setSignOutError('');
|
||||
setSigningOut(true);
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
|
||||
if (error) {
|
||||
setSignOutError(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
await FirebaseAuthentication.signOut();
|
||||
} catch {
|
||||
// Ignore if no native Google session exists.
|
||||
}
|
||||
}
|
||||
|
||||
setEmail(null);
|
||||
history.replace('/auth');
|
||||
} catch (error) {
|
||||
setSignOutError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unable to sign out right now.',
|
||||
);
|
||||
} finally {
|
||||
setSigningOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (sessionChecked && !email) {
|
||||
return <RedirectAny to="/auth" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonTitle>Account</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent fullscreen>
|
||||
<div className="dashboard-shell ion-padding account-shell">
|
||||
<section className="account-hero-card">
|
||||
<div className="account-hero-image-wrap">
|
||||
<img
|
||||
src={runnerDrinkingWaterImage}
|
||||
alt="Runner drinking water after a run"
|
||||
className="account-hero-image"
|
||||
/>
|
||||
<div className="account-hero-overlay" />
|
||||
</div>
|
||||
|
||||
<div className="account-hero-content">
|
||||
<span className="eyebrow">Recovery & account</span>
|
||||
<h1>{email ?? 'Loading...'}</h1>
|
||||
<p>
|
||||
Stay hydrated, stay consistent, and keep your habit history
|
||||
safely synced across sessions.
|
||||
</p>
|
||||
|
||||
<div className="account-highlight-row">
|
||||
<div className="account-highlight-chip">
|
||||
<IonIcon icon={waterOutline} />
|
||||
<span>Recovery mindset</span>
|
||||
</div>
|
||||
<div className="account-highlight-chip subtle">
|
||||
<IonIcon icon={fitnessOutline} />
|
||||
<span>Built for routines</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="stats-card account-details-card">
|
||||
<IonItem lines="none" className="account-info-item">
|
||||
<IonLabel>
|
||||
<h2>Email</h2>
|
||||
<p>{email ?? '—'}</p>
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
|
||||
<div className="account-trust-row">
|
||||
<div className="account-trust-icon">
|
||||
<IonIcon icon={shieldCheckmarkOutline} />
|
||||
</div>
|
||||
<div>
|
||||
<h3>Your data is synced</h3>
|
||||
<p>
|
||||
Sign in to keep your streaks and progress available whenever
|
||||
you come back.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
className="account-primary-button"
|
||||
routerLink="/home"
|
||||
>
|
||||
Back to Habits
|
||||
</IonButton>
|
||||
{signOutError ? (
|
||||
<IonText color="danger">
|
||||
<p className="auth-feedback">{signOutError}</p>
|
||||
</IonText>
|
||||
) : null}
|
||||
<div></div>
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill="outline"
|
||||
color="danger"
|
||||
className="account-secondary-button"
|
||||
onClick={handleSignOut}
|
||||
disabled={signingOut}
|
||||
>
|
||||
{signingOut ? 'Signing Out…' : 'Sign Out'}
|
||||
</IonButton>
|
||||
</div>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Account;
|
||||
@@ -1,395 +1,573 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import {
|
||||
IonButton,
|
||||
IonCard,
|
||||
IonCardContent,
|
||||
IonContent,
|
||||
IonIcon,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonPage,
|
||||
IonSegment,
|
||||
IonSegmentButton,
|
||||
IonText,
|
||||
IonContent,
|
||||
IonSpinner,
|
||||
IonIcon,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Redirect, useHistory } from 'react-router-dom';
|
||||
import {
|
||||
Style,
|
||||
setStatusBarBackground,
|
||||
setStatusBarStyle,
|
||||
} from '../utils/statusBar';
|
||||
import { eyeOutline, eyeOffOutline } from 'ionicons/icons';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useProfileLoader } from '../hooks/useProfileLoader';
|
||||
|
||||
const Auth: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const [mode, setMode] = useState<'login' | 'signup'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const { user } = useAuth();
|
||||
const { loadProfileAndNavigate } = useProfileLoader();
|
||||
|
||||
const [tab, setTab] = useState<'login' | 'register'>('login');
|
||||
|
||||
const [loginEmail, setLoginEmail] = useState('');
|
||||
const [loginPassword, setLoginPassword] = useState('');
|
||||
const [showLoginPassword, setShowLoginPassword] = useState(false);
|
||||
|
||||
const [registerEmail, setRegisterEmail] = useState('');
|
||||
const [registerPassword, setRegisterPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showRegisterPassword, setShowRegisterPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [googleLoading, setGoogleLoading] = useState(false);
|
||||
const [appleLoading, setAppleLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [info, setInfo] = useState('');
|
||||
const [verificationMessage, setVerificationMessage] = useState('');
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
const [hasSession, setHasSession] = useState(false);
|
||||
|
||||
const [overlayVisible, setOverlayVisible] = useState(false);
|
||||
const [overlayError, setOverlayError] = useState(false);
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setTab('login');
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadProfileAndNavigate(setOverlayVisible, setOverlayError);
|
||||
}
|
||||
}, [user, loadProfileAndNavigate]);
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setError(msg);
|
||||
setTimeout(() => {
|
||||
setError((current) => (current === msg ? '' : current));
|
||||
}, 4000);
|
||||
setTimeout(() => setError(''), 4000);
|
||||
};
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
setStatusBarBackground('#f6f7fb');
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
if (!mounted) return;
|
||||
setHasSession(Boolean(data.session));
|
||||
setSessionChecked(true);
|
||||
});
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
if (!mounted) return;
|
||||
setHasSession(Boolean(session));
|
||||
setSessionChecked(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const formValid = useMemo(() => {
|
||||
return email.trim().length > 3 && password.length >= 6;
|
||||
}, [email, password]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const handleSignIn = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setInfo('');
|
||||
setVerificationMessage('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (mode === 'login') {
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email: loginEmail,
|
||||
password: loginPassword,
|
||||
});
|
||||
|
||||
if (signInError) {
|
||||
showError(signInError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
history.replace('/home');
|
||||
return;
|
||||
if (error) {
|
||||
if (error.message.toLowerCase().includes('email not confirmed')) {
|
||||
await supabase.auth.resend({ type: 'signup', email: loginEmail });
|
||||
history.push('/verify-email', { email: loginEmail, resent: true });
|
||||
} else {
|
||||
showError(error.message);
|
||||
}
|
||||
|
||||
const { data, error: signUpError } = await supabase.auth.signUp({
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
|
||||
if (signUpError) {
|
||||
showError(signUpError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.session) {
|
||||
history.replace('/home');
|
||||
return;
|
||||
}
|
||||
|
||||
setVerificationMessage(
|
||||
'Account created! Check your email for a verification link.'
|
||||
);
|
||||
setInfo('Once verified, come back and sign in.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// keep loading true waiting for user effect
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
const handleSignUp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (registerPassword !== confirmPassword) {
|
||||
showError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setInfo('');
|
||||
setVerificationMessage('');
|
||||
setGoogleLoading(true);
|
||||
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
showError(
|
||||
'Google Sign-In is only available in the native app. Use email to sign in here.'
|
||||
);
|
||||
setGoogleLoading(false);
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email: registerEmail,
|
||||
password: registerPassword,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
showError(error.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await FirebaseAuthentication.signInWithGoogle();
|
||||
const idToken = result.credential?.idToken;
|
||||
|
||||
if (!idToken) {
|
||||
showError('Google sign-in did not return an ID token.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: googleAuthError } = await supabase.auth.signInWithIdToken({
|
||||
provider: 'google',
|
||||
token: idToken,
|
||||
});
|
||||
|
||||
if (googleAuthError) {
|
||||
showError(googleAuthError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
history.replace('/home');
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Google sign-in failed.';
|
||||
showError(message);
|
||||
} finally {
|
||||
setGoogleLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
history.push('/verify-email', { email: registerEmail });
|
||||
};
|
||||
|
||||
const handleAppleLogin = async () => {
|
||||
setError('');
|
||||
setInfo('');
|
||||
setVerificationMessage('');
|
||||
setAppleLoading(true);
|
||||
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
showError(
|
||||
'Apple Sign-In is only available in the native app. Use email to sign in here.'
|
||||
);
|
||||
setAppleLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await FirebaseAuthentication.signInWithApple({
|
||||
skipNativeAuth: true,
|
||||
});
|
||||
const idToken = result.credential?.idToken;
|
||||
const rawNonce = result.credential?.nonce;
|
||||
|
||||
if (!idToken) {
|
||||
showError('Apple sign-in did not return an ID token.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: appleAuthError } = await supabase.auth.signInWithIdToken({
|
||||
provider: 'apple',
|
||||
token: idToken,
|
||||
nonce: rawNonce || undefined,
|
||||
});
|
||||
|
||||
if (appleAuthError) {
|
||||
showError(appleAuthError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
history.replace('/home');
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Apple sign-in failed.';
|
||||
showError(message);
|
||||
} finally {
|
||||
setAppleLoading(false);
|
||||
}
|
||||
const handleForgotPassword = () => {
|
||||
history.push('/forgot-password');
|
||||
};
|
||||
|
||||
if (sessionChecked && hasSession) {
|
||||
return <Redirect to="/home" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent fullscreen style={{ '--background': '#f6f7fb' }}>
|
||||
<div className="auth-shell">
|
||||
<div className="auth-hero">
|
||||
<span className="eyebrow">Protect your streaks</span>
|
||||
<h1>Build habits that actually stick.</h1>
|
||||
<p>
|
||||
Track daily wins, recover faster from misses, and see which habits
|
||||
are getting real momentum.
|
||||
</p>
|
||||
<IonPage style={{ background: 'var(--color-bg)' }}>
|
||||
<IonContent
|
||||
style={{
|
||||
'--background': 'var(--color-bg-gradient)',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': '0px',
|
||||
'--padding-bottom': '0px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding:
|
||||
'calc(var(--ion-safe-area-top, 0px) + 18px) 20px calc(var(--ion-safe-area-bottom, 0px) + 28px)',
|
||||
minHeight: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: '24px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
margin: '0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '14px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
alignSelf: 'flex-start',
|
||||
background: 'var(--color-surface)',
|
||||
color: 'var(--color-brand)',
|
||||
borderRadius: '999px',
|
||||
padding: '8px 14px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
QuoteLink
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: '1.2',
|
||||
}}
|
||||
>
|
||||
Instant prices.{' '}
|
||||
<span
|
||||
style={{ color: 'var(--color-accent)', fontWeight: '700' }}
|
||||
>
|
||||
Smart decisions.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '34px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: '1.02',
|
||||
letterSpacing: '-0.03em',
|
||||
maxWidth: '330px',
|
||||
}}
|
||||
>
|
||||
Modern procurement for buyers and suppliers.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-secondary)',
|
||||
lineHeight: '1.6',
|
||||
maxWidth: '320px',
|
||||
}}
|
||||
>
|
||||
Send a request, compare real quotes, and move faster with a
|
||||
clean professional workflow.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IonCard className="auth-card">
|
||||
<IonCardContent>
|
||||
<IonSegment
|
||||
value={mode}
|
||||
onIonChange={(e) =>
|
||||
setMode(e.detail.value as 'login' | 'signup')
|
||||
}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: '24px',
|
||||
padding: '24px',
|
||||
margin: '0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '22px',
|
||||
fontWeight: '700',
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: '1.15',
|
||||
}}
|
||||
>
|
||||
<IonSegmentButton value="login">
|
||||
<IonLabel>Login</IonLabel>
|
||||
</IonSegmentButton>
|
||||
<IonSegmentButton value="signup">
|
||||
<IonLabel>Sign Up</IonLabel>
|
||||
</IonSegmentButton>
|
||||
</IonSegment>
|
||||
{tab === 'login' ? 'Welcome back' : 'Create your account'}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-secondary)',
|
||||
lineHeight: '1.55',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{tab === 'login'
|
||||
? 'Sign in to manage requests and supplier quotations.'
|
||||
: 'Join QuoteLink to start sending or responding to RFQs.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{verificationMessage ? (
|
||||
<div className="auth-message-block">
|
||||
<h2>Check your inbox</h2>
|
||||
<p>{verificationMessage}</p>
|
||||
<IonText color="medium">
|
||||
<p>{info}</p>
|
||||
</IonText>
|
||||
<IonButton fill="clear" onClick={() => setMode('login')}>
|
||||
Back to Login
|
||||
</IonButton>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
background: 'var(--color-surface-raised)',
|
||||
borderRadius: '999px',
|
||||
padding: '4px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setTab('login')}
|
||||
style={{
|
||||
flex: 1,
|
||||
background:
|
||||
tab === 'login' ? 'var(--color-brand)' : 'transparent',
|
||||
color:
|
||||
tab === 'login' ? '#ffffff' : 'var(--color-brand-strong)',
|
||||
borderRadius: '999px',
|
||||
padding: '12px 18px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Log In
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('register')}
|
||||
style={{
|
||||
flex: 1,
|
||||
background:
|
||||
tab === 'register' ? 'var(--color-brand)' : 'transparent',
|
||||
color:
|
||||
tab === 'register'
|
||||
? '#ffffff'
|
||||
: 'var(--color-brand-strong)',
|
||||
borderRadius: '999px',
|
||||
padding: '12px 18px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-danger-soft)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 14px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: 'var(--color-danger-text)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'login' ? (
|
||||
<form
|
||||
onSubmit={handleSignIn}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={loginEmail}
|
||||
onChange={(e) => setLoginEmail(e.target.value)}
|
||||
style={{
|
||||
background: 'var(--color-surface-raised)',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
width: '100%',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type={showLoginPassword ? 'text' : 'password'}
|
||||
placeholder="Password"
|
||||
value={loginPassword}
|
||||
onChange={(e) => setLoginPassword(e.target.value)}
|
||||
style={{
|
||||
background: 'var(--color-surface-raised)',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 40px 0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
width: '100%',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<IonIcon
|
||||
icon={showLoginPassword ? eyeOffOutline : eyeOutline}
|
||||
onClick={() => setShowLoginPassword(!showLoginPassword)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '14px',
|
||||
top: '14px',
|
||||
fontSize: '20px',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="auth-form">
|
||||
<IonItem lines="none" className="auth-input-item">
|
||||
<IonInput
|
||||
type="email"
|
||||
label="Email"
|
||||
labelPlacement="stacked"
|
||||
value={email}
|
||||
placeholder="you@example.com"
|
||||
onIonInput={(e) => setEmail(e.detail.value ?? '')}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<IonItem lines="none" className="auth-input-item">
|
||||
<IonInput
|
||||
type="password"
|
||||
label="Password"
|
||||
labelPlacement="stacked"
|
||||
value={password}
|
||||
placeholder="At least 6 characters"
|
||||
onIonInput={(e) => setPassword(e.detail.value ?? '')}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
{error ? (
|
||||
<IonText color="danger">
|
||||
<p className="auth-feedback">{error}</p>
|
||||
</IonText>
|
||||
) : null}
|
||||
|
||||
{info ? (
|
||||
<IonText color="medium">
|
||||
<p className="auth-feedback">{info}</p>
|
||||
</IonText>
|
||||
) : null}
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
disabled={
|
||||
!formValid || loading || googleLoading || appleLoading
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleForgotPassword}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: '0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-accent)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading
|
||||
? 'Please wait…'
|
||||
: mode === 'login'
|
||||
? 'Log In'
|
||||
: 'Create Account'}
|
||||
</IonButton>
|
||||
|
||||
{mode === 'login' ? (
|
||||
<>
|
||||
<div className="auth-divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill="outline"
|
||||
className="google-auth-button"
|
||||
disabled={loading || googleLoading || appleLoading}
|
||||
onClick={handleGoogleLogin}
|
||||
>
|
||||
<span
|
||||
slot="start"
|
||||
className="google-svg-wrap"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
marginRight: '8px',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{googleLoading ? 'Connecting…' : 'Continue with Google'}
|
||||
</IonButton>
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill="outline"
|
||||
className="apple-auth-button"
|
||||
disabled={loading || googleLoading || appleLoading}
|
||||
onClick={handleAppleLogin}
|
||||
>
|
||||
<span
|
||||
slot="start"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
marginRight: '8px',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||
</svg>
|
||||
</span>
|
||||
{appleLoading ? 'Connecting…' : 'Continue with Apple'}
|
||||
</IonButton>
|
||||
</>
|
||||
) : null}
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: 'var(--color-brand)',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '16px 20px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '700',
|
||||
color: '#ffffff',
|
||||
cursor: 'pointer',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<IonSpinner
|
||||
name="crescent"
|
||||
style={{ color: '#fff', width: '20px', height: '20px' }}
|
||||
/>
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSignUp}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={registerEmail}
|
||||
onChange={(e) => setRegisterEmail(e.target.value)}
|
||||
style={{
|
||||
background: 'var(--color-surface-raised)',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
width: '100%',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type={showRegisterPassword ? 'text' : 'password'}
|
||||
placeholder="Create a password"
|
||||
value={registerPassword}
|
||||
onChange={(e) => setRegisterPassword(e.target.value)}
|
||||
style={{
|
||||
background: 'var(--color-surface-raised)',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 40px 0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
width: '100%',
|
||||
}}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<IonIcon
|
||||
icon={showRegisterPassword ? eyeOffOutline : eyeOutline}
|
||||
onClick={() =>
|
||||
setShowRegisterPassword(!showRegisterPassword)
|
||||
}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '14px',
|
||||
top: '14px',
|
||||
fontSize: '20px',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
placeholder="Confirm password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
style={{
|
||||
background: 'var(--color-surface-raised)',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 40px 0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
width: '100%',
|
||||
}}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<IonIcon
|
||||
icon={showConfirmPassword ? eyeOffOutline : eyeOutline}
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '14px',
|
||||
top: '14px',
|
||||
fontSize: '20px',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: 'var(--color-brand)',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '16px 20px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '700',
|
||||
color: '#ffffff',
|
||||
cursor: 'pointer',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<IonSpinner
|
||||
name="crescent"
|
||||
style={{ color: '#fff', width: '20px', height: '20px' }}
|
||||
/>
|
||||
) : (
|
||||
'Create Account'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{overlayVisible && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(18, 18, 18, 0.78)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
{overlayError ? (
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--color-danger-text)',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: '24px',
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
Error loading profile. Please restart the app.
|
||||
</div>
|
||||
) : (
|
||||
<IonSpinner
|
||||
name="crescent"
|
||||
style={{
|
||||
color: 'var(--color-brand)',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import {
|
||||
IonPage,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonButtons,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonTitle,
|
||||
IonContent,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { chevronBackOutline, mailOpenOutline } from 'ionicons/icons';
|
||||
import { supabase } from '../supabase';
|
||||
import QuotationCard from '../components/QuotationCard';
|
||||
|
||||
const BuyerQuoteList: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const [rfqSummary, setRfqSummary] = useState<{
|
||||
title: string;
|
||||
referenceNumber: string;
|
||||
} | null>(null);
|
||||
const [quotes, setQuotes] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [sortMode, setSortMode] = useState<'price' | 'supplier'>('price');
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
loadQuotes();
|
||||
});
|
||||
|
||||
const loadQuotes = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { data: rfq, error: rfqErr } = await supabase
|
||||
.from('rfqs')
|
||||
.select('title, reference_number')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
if (rfqErr) throw rfqErr;
|
||||
setRfqSummary({
|
||||
title: rfq.title,
|
||||
referenceNumber: rfq.reference_number,
|
||||
});
|
||||
|
||||
const { data: qData, error: qErr } = await supabase
|
||||
.from('quotations')
|
||||
.select(
|
||||
'id, price_amount, delivery_option, status, supplier_id, profiles(full_name, company_name)'
|
||||
)
|
||||
.eq('rfq_id', id);
|
||||
if (qErr) throw qErr;
|
||||
|
||||
const mapped = (qData || []).map((q) => ({
|
||||
id: q.id,
|
||||
priceAmount: q.price_amount,
|
||||
deliveryOption: q.delivery_option,
|
||||
status: q.status,
|
||||
supplierName:
|
||||
q.profiles?.company_name || q.profiles?.full_name || 'Supplier',
|
||||
}));
|
||||
|
||||
setQuotes(mapped);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError('Failed to load quotations.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSortedQuotes = () => {
|
||||
if (sortMode === 'price') {
|
||||
return [...quotes].sort((a, b) => a.priceAmount - b.priceAmount);
|
||||
} else {
|
||||
return [...quotes].sort((a, b) =>
|
||||
a.supplierName.localeCompare(b.supplierName)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcceptQuote = async (quoteId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Update selected quotation to accepted
|
||||
const { error: accErr } = await supabase
|
||||
.from('quotations')
|
||||
.update({ status: 'accepted', updated_at: new Date().toISOString() })
|
||||
.eq('id', quoteId);
|
||||
if (accErr) throw accErr;
|
||||
|
||||
// Update sibling quotations to declined
|
||||
const { error: decErr } = await supabase
|
||||
.from('quotations')
|
||||
.update({ status: 'declined', updated_at: new Date().toISOString() })
|
||||
.eq('rfq_id', id)
|
||||
.neq('id', quoteId);
|
||||
if (decErr) throw decErr;
|
||||
|
||||
// Close RFQ
|
||||
const { error: rfqErr } = await supabase
|
||||
.from('rfqs')
|
||||
.update({ status: 'closed', updated_at: new Date().toISOString() })
|
||||
.eq('id', id);
|
||||
if (rfqErr) throw rfqErr;
|
||||
|
||||
await loadQuotes();
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError(err.message || 'Failed to accept quote.');
|
||||
setLoading(false);
|
||||
setTimeout(() => setError(''), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeclineQuote = async (quoteId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { error } = await supabase
|
||||
.from('quotations')
|
||||
.update({ status: 'declined', updated_at: new Date().toISOString() })
|
||||
.eq('id', quoteId);
|
||||
if (error) throw error;
|
||||
|
||||
await loadQuotes();
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError(err.message || 'Failed to decline quote.');
|
||||
setLoading(false);
|
||||
setTimeout(() => setError(''), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedQuotes = getSortedQuotes();
|
||||
|
||||
return (
|
||||
<IonPage style={{ background: '#f7faf7' }}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': '#f7faf7' }}>
|
||||
<IonButtons slot="start">
|
||||
<IonButton
|
||||
onClick={() => history.goBack()}
|
||||
style={{ color: '#14532d' }}
|
||||
>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle
|
||||
style={{ fontSize: '18px', fontWeight: '700', color: '#0f1720' }}
|
||||
>
|
||||
Compare Quotes
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent
|
||||
style={{
|
||||
'--background': '#f7faf7',
|
||||
'--padding-start': '20px',
|
||||
'--padding-end': '20px',
|
||||
'--padding-top': '20px',
|
||||
'--padding-bottom': 'calc(28px + var(--ion-safe-area-bottom))',
|
||||
}}
|
||||
>
|
||||
{loading && quotes.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '40px',
|
||||
}}
|
||||
>
|
||||
<IonSpinner name="crescent" style={{ color: '#166534' }} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{rfqSummary && (
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '20px',
|
||||
margin: '0 0 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
color: '#b45309',
|
||||
background: 'rgba(245,158,11,0.14)',
|
||||
borderRadius: '999px',
|
||||
padding: '4px 10px',
|
||||
width: 'max-content',
|
||||
}}
|
||||
>
|
||||
{rfqSummary.referenceNumber}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#0f1720',
|
||||
}}
|
||||
>
|
||||
{rfqSummary.title}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(239, 68, 68, 0.10)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 14px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: '#b91c1c',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ margin: '0 0 12px', display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => setSortMode('price')}
|
||||
style={{
|
||||
background: sortMode === 'price' ? '#166534' : '#ffffff',
|
||||
color: sortMode === 'price' ? '#ffffff' : '#14532d',
|
||||
borderRadius: '999px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Sort by Price
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSortMode('supplier')}
|
||||
style={{
|
||||
background: sortMode === 'supplier' ? '#166534' : '#ffffff',
|
||||
color: sortMode === 'supplier' ? '#ffffff' : '#14532d',
|
||||
borderRadius: '999px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Sort by Supplier
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sortedQuotes.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
{sortedQuotes.map((q) => (
|
||||
<QuotationCard
|
||||
key={q.id}
|
||||
supplierName={q.supplierName}
|
||||
priceLabel={`$${q.priceAmount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
|
||||
deliveryOption={q.deliveryOption}
|
||||
status={q.status}
|
||||
onAccept={() => handleAcceptQuote(q.id)}
|
||||
onDecline={() => handleDeclineQuote(q.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '28px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={mailOpenOutline}
|
||||
style={{ fontSize: '48px', color: '#6c7a71' }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: '#6c7a71',
|
||||
}}
|
||||
>
|
||||
No quotations available yet.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default BuyerQuoteList;
|
||||
@@ -0,0 +1,343 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
IonIcon,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
addOutline,
|
||||
searchOutline,
|
||||
optionsOutline,
|
||||
documentTextOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { supabase } from '../supabase';
|
||||
import { useProfile } from '../contexts/ProfileContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import RequestListCard from '../components/RequestListCard';
|
||||
|
||||
interface RFQListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
referenceNumber: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const BuyerRequests: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user } = useAuth();
|
||||
const { profile } = useProfile();
|
||||
|
||||
const [items, setItems] = useState<RFQListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [activeStatus, setActiveStatus] = useState<
|
||||
'all' | 'open' | 'quoted' | 'closed'
|
||||
>('all');
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
loadRequests();
|
||||
});
|
||||
|
||||
const loadRequests = async () => {
|
||||
if (!user || !profile) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (profile.role === 'buyer') {
|
||||
const { data, error: err } = await supabase
|
||||
.from('rfqs')
|
||||
.select('*')
|
||||
.eq('buyer_id', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
if (err) throw err;
|
||||
setItems(
|
||||
data.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
referenceNumber: d.reference_number,
|
||||
status: d.status,
|
||||
createdAt: d.created_at,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
const { data, error: err } = await supabase
|
||||
.from('rfq_recipients')
|
||||
.select('id, rfqs!inner(*)')
|
||||
.eq('supplier_id', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
if (err) throw err;
|
||||
const mapped = (data || []).map((row: any) => ({
|
||||
id: row.rfqs.id,
|
||||
title: row.rfqs.title,
|
||||
referenceNumber: row.rfqs.reference_number,
|
||||
status: row.rfqs.status,
|
||||
createdAt: row.rfqs.created_at,
|
||||
}));
|
||||
setItems(mapped);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError('Failed to load requests.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
const matchStatus = activeStatus === 'all' || item.status === activeStatus;
|
||||
const searchLower = searchText.toLowerCase();
|
||||
const matchSearch =
|
||||
item.title.toLowerCase().includes(searchLower) ||
|
||||
item.referenceNumber.toLowerCase().includes(searchLower);
|
||||
return matchStatus && matchSearch;
|
||||
});
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setSearchText('');
|
||||
setActiveStatus('all');
|
||||
};
|
||||
|
||||
if (!profile) return null;
|
||||
|
||||
return (
|
||||
<IonPage style={{ background: 'var(--color-bg)' }}>
|
||||
<IonContent
|
||||
style={{
|
||||
'--background': 'var(--color-bg-gradient)',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': '0px',
|
||||
'--padding-bottom': 'calc(96px + var(--ion-safe-area-bottom))',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: 'calc(16px + var(--ion-safe-area-top, 0px)) 20px 12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '3px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '700',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--color-brand)',
|
||||
}}
|
||||
>
|
||||
{profile.role === 'buyer' ? 'Buyer Workspace' : 'Supplier Inbox'}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
{profile.role === 'buyer' ? 'My Requests' : 'Assigned RFQs'}
|
||||
</div>
|
||||
</div>
|
||||
{profile.role === 'buyer' && (
|
||||
<div
|
||||
onClick={() => history.push('/rfq/new')}
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '12px',
|
||||
background: 'var(--color-brand)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={addOutline}
|
||||
style={{ color: '#ffffff', fontSize: '24px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
margin: '0 20px 12px',
|
||||
background: 'rgba(239, 68, 68, 0.10)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 14px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: '#b91c1c',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ margin: '0 20px 12px', display: 'flex', gap: '10px' }}>
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<IonIcon
|
||||
icon={searchOutline}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '14px',
|
||||
top: '14px',
|
||||
fontSize: '18px',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by title or reference..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{
|
||||
background: 'var(--color-surface-raised)',
|
||||
borderRadius: '24px',
|
||||
border: 'none',
|
||||
padding: '0 16px 0 40px',
|
||||
height: '44px',
|
||||
width: '100%',
|
||||
fontSize: '14px',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleResetFilters}
|
||||
style={{
|
||||
background: 'var(--color-surface-raised)',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={optionsOutline}
|
||||
style={{ fontSize: '20px', color: 'var(--color-brand)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
margin: '0 20px 16px',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
overflowX: 'auto',
|
||||
paddingBottom: '4px',
|
||||
}}
|
||||
>
|
||||
{(['all', 'open', 'quoted', 'closed'] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setActiveStatus(status)}
|
||||
style={{
|
||||
background:
|
||||
activeStatus === status
|
||||
? 'var(--color-brand)'
|
||||
: 'var(--color-surface)',
|
||||
color:
|
||||
activeStatus === status
|
||||
? '#ffffff'
|
||||
: 'var(--color-brand-strong)',
|
||||
borderRadius: '999px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
margin: '0 20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<IonSpinner name="crescent" style={{ color: '#166534' }} />
|
||||
</div>
|
||||
) : filteredItems.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
margin: '0 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
{filteredItems.map((item) => (
|
||||
<RequestListCard
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
subtitle={new Date(item.createdAt).toLocaleDateString()}
|
||||
referenceNumber={item.referenceNumber}
|
||||
status={item.status}
|
||||
onClick={() => history.push(`/rfq/${item.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
margin: '0 20px',
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: '24px',
|
||||
padding: '28px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={documentTextOutline}
|
||||
style={{ fontSize: '48px', color: 'var(--color-text-tertiary)' }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
No requests found.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default BuyerRequests;
|
||||
@@ -0,0 +1,309 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import {
|
||||
IonPage,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonButtons,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonTitle,
|
||||
IonContent,
|
||||
IonSpinner,
|
||||
} from '@ionic/react';
|
||||
import { chevronBackOutline } from 'ionicons/icons';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useProfile } from '../contexts/ProfileContext';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
|
||||
const EditProfile: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user } = useAuth();
|
||||
const { profile, setProfile } = useProfile();
|
||||
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [city, setCity] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setFullName(profile.full_name || '');
|
||||
setCompanyName(profile.company_name || '');
|
||||
setPhone(profile.phone || '');
|
||||
setCity(profile.city || '');
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setError(msg);
|
||||
setTimeout(() => setError(''), 4000);
|
||||
};
|
||||
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user || !profile) return;
|
||||
if (!fullName) {
|
||||
showError('Please enter your full name');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
|
||||
const updates = {
|
||||
full_name: fullName,
|
||||
company_name: companyName || null,
|
||||
phone: phone || null,
|
||||
city: city || null,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('profiles')
|
||||
.update(updates)
|
||||
.eq('id', user.id);
|
||||
|
||||
if (updateError) {
|
||||
showError(updateError.message);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedProfile = { ...profile, ...updates };
|
||||
setProfile(updatedProfile);
|
||||
await Preferences.set({
|
||||
key: `profile_${user.id}`,
|
||||
value: JSON.stringify(updatedProfile),
|
||||
});
|
||||
|
||||
setSaving(false);
|
||||
history.goBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage style={{ background: 'var(--color-bg)' }}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar
|
||||
style={{ '--background': 'var(--color-bg)' } as React.CSSProperties}
|
||||
>
|
||||
<IonButtons slot="start">
|
||||
<IonButton
|
||||
onClick={() => history.goBack()}
|
||||
style={{ color: 'var(--color-brand-strong)' }}
|
||||
>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '700',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
Edit Profile
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent
|
||||
style={{
|
||||
'--background': 'var(--color-bg)',
|
||||
'--padding-start': '20px',
|
||||
'--padding-end': '20px',
|
||||
'--padding-top': '20px',
|
||||
'--padding-bottom': 'calc(28px + var(--ion-safe-area-bottom))',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: '24px',
|
||||
padding: '24px',
|
||||
margin: '0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '18px',
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-danger-soft)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 14px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: 'var(--color-danger-text)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleSaveProfile}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
|
||||
>
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. John Doe"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
style={{
|
||||
background: 'var(--color-surface-raised)',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
width: '100%',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
Company Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. BuildRight Supplies"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
style={{
|
||||
background: 'var(--color-surface-raised)',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
City
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Lagos"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
style={{
|
||||
background: 'var(--color-surface-raised)',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="e.g. +234 800 000 0000"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
style={{
|
||||
background: 'var(--color-surface-raised)',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !fullName}
|
||||
style={{
|
||||
background: 'var(--color-brand)',
|
||||
color: '#ffffff',
|
||||
borderRadius: '999px',
|
||||
padding: '16px 20px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '700',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
opacity: saving || !fullName ? 0.7 : 1,
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
{saving ? (
|
||||
<IonSpinner
|
||||
name="crescent"
|
||||
style={{ color: '#fff', width: '20px', height: '20px' }}
|
||||
/>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditProfile;
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import {
|
||||
IonPage,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonButtons,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonTitle,
|
||||
IonContent,
|
||||
IonSpinner,
|
||||
} from '@ionic/react';
|
||||
import { chevronBackOutline } from 'ionicons/icons';
|
||||
import { supabase } from '../supabase';
|
||||
|
||||
const ForgotPassword: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setError(msg);
|
||||
setTimeout(() => setError(''), 4000);
|
||||
};
|
||||
|
||||
const handleRequestReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email);
|
||||
|
||||
if (error) {
|
||||
showError(error.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
history.push('/verify-reset', { email });
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage style={{ background: '#f7faf7' }}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': '#f7faf7' }}>
|
||||
<IonButtons slot="start">
|
||||
<IonButton
|
||||
onClick={() => history.goBack()}
|
||||
style={{ color: '#14532d' }}
|
||||
>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle
|
||||
style={{ fontSize: '18px', fontWeight: '700', color: '#0f1720' }}
|
||||
>
|
||||
Reset Password
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent
|
||||
style={{
|
||||
'--background': '#f7faf7',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': '0px',
|
||||
'--padding-bottom': '0px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '24px',
|
||||
margin: '20px 20px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '18px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '22px',
|
||||
fontWeight: '700',
|
||||
color: '#0f1720',
|
||||
margin: '0 0 8px 0',
|
||||
}}
|
||||
>
|
||||
Forgot password?
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: '#5f6d63',
|
||||
margin: '0',
|
||||
}}
|
||||
>
|
||||
Enter your email address and we'll send you a 6-digit code to
|
||||
reset your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(239, 68, 68, 0.10)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 14px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: '#b91c1c',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleRequestReset}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={{
|
||||
background: '#f4f7f4',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '400',
|
||||
color: '#0f1720',
|
||||
width: '100%',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !email}
|
||||
style={{
|
||||
background: '#166534',
|
||||
color: '#ffffff',
|
||||
borderRadius: '999px',
|
||||
padding: '16px 20px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '700',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
opacity: loading || !email ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<IonSpinner
|
||||
name="crescent"
|
||||
style={{ color: '#fff', width: '20px', height: '20px' }}
|
||||
/>
|
||||
) : (
|
||||
'Send Code'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPassword;
|
||||
@@ -1,623 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
IonAlert,
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonPage,
|
||||
IonSkeletonText,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { IonBackButton } from '@ionic/react';
|
||||
import {
|
||||
checkmarkOutline,
|
||||
createOutline,
|
||||
ellipseOutline,
|
||||
flameOutline,
|
||||
sparklesOutline,
|
||||
trashOutline,
|
||||
trendingUpOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { Redirect, useHistory, useParams } from 'react-router-dom';
|
||||
import type { Tables } from '../database.types';
|
||||
import { supabase } from '../supabase';
|
||||
import HabitFormModal from '../components/HabitFormModal';
|
||||
import {
|
||||
Style,
|
||||
setStatusBarBackground,
|
||||
setStatusBarStyle,
|
||||
} from '../utils/statusBar';
|
||||
|
||||
interface HabitWithCompletions extends Tables<'habits'> {
|
||||
completions: Tables<'habit_completions'>[];
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||
|
||||
const calculateStreak = (dates: string[]) => {
|
||||
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
|
||||
let streak = 0;
|
||||
let cursor = new Date();
|
||||
|
||||
if (!unique.includes(formatDate(cursor))) {
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
|
||||
while (unique.includes(formatDate(cursor))) {
|
||||
streak += 1;
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
|
||||
return streak;
|
||||
};
|
||||
|
||||
const HabitDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const history = useHistory();
|
||||
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [habit, setHabit] = useState<HabitWithCompletions | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
|
||||
const loadHabit = useCallback(async () => {
|
||||
setError('');
|
||||
|
||||
const {
|
||||
data: { session },
|
||||
error: sessionError,
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (sessionError) {
|
||||
setError(sessionError.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
setUserId(null);
|
||||
setSessionChecked(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setUserId(session.user.id);
|
||||
setSessionChecked(true);
|
||||
|
||||
const { data, error: habitError } = await supabase
|
||||
.from('habits')
|
||||
.select('*, completions:habit_completions(*)')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (habitError) {
|
||||
if (habitError.code === 'PGRST116') {
|
||||
setHabit(null);
|
||||
} else {
|
||||
setError(habitError.message);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setHabit(data as HabitWithCompletions);
|
||||
setLoading(false);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadHabit();
|
||||
}, [loadHabit]);
|
||||
|
||||
const completionDates = useMemo(
|
||||
() => habit?.completions.map((c) => c.completed_on) ?? [],
|
||||
[habit],
|
||||
);
|
||||
|
||||
const today = formatDate(new Date());
|
||||
const doneToday = completionDates.includes(today);
|
||||
|
||||
const currentStreak = useMemo(
|
||||
() => calculateStreak(completionDates),
|
||||
[completionDates],
|
||||
);
|
||||
|
||||
const totalCompletions = completionDates.length;
|
||||
|
||||
// Calculate longest streak
|
||||
const longestStreak = useMemo(() => {
|
||||
const unique = Array.from(new Set(completionDates)).sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
if (unique.length === 0) return 0;
|
||||
let maxStreak = 1;
|
||||
let current = 1;
|
||||
for (let i = 1; i < unique.length; i++) {
|
||||
const prev = new Date(unique[i - 1]);
|
||||
const curr = new Date(unique[i]);
|
||||
const diffDays =
|
||||
(curr.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (diffDays === 1) {
|
||||
current += 1;
|
||||
maxStreak = Math.max(maxStreak, current);
|
||||
} else {
|
||||
current = 1;
|
||||
}
|
||||
}
|
||||
return maxStreak;
|
||||
}, [completionDates]);
|
||||
|
||||
// Days since habit creation (or first completion, whichever is earlier)
|
||||
const daysSinceStart = useMemo(() => {
|
||||
const startDate = habit
|
||||
? new Date(
|
||||
Math.min(
|
||||
new Date(habit.created_at).getTime(),
|
||||
...completionDates.map((d) => new Date(d).getTime()),
|
||||
Date.now(),
|
||||
),
|
||||
)
|
||||
: new Date();
|
||||
const diff = Math.ceil(
|
||||
(Date.now() - startDate.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
return Math.max(diff, 1);
|
||||
}, [habit, completionDates]);
|
||||
|
||||
const consistencyRate = Math.round((totalCompletions / daysSinceStart) * 100);
|
||||
|
||||
// 30-day heatmap data
|
||||
const heatmapDays = useMemo(() => {
|
||||
return Array.from({ length: 30 }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (29 - i));
|
||||
const key = formatDate(date);
|
||||
return {
|
||||
key,
|
||||
completed: completionDates.includes(key),
|
||||
isToday: key === today,
|
||||
};
|
||||
});
|
||||
}, [completionDates, today]);
|
||||
|
||||
const toggleToday = async () => {
|
||||
if (!userId || !habit) return;
|
||||
setBusy(true);
|
||||
|
||||
if (doneToday) {
|
||||
const existing = habit.completions.find((c) => c.completed_on === today);
|
||||
if (existing) {
|
||||
const { error: deleteError } = await supabase
|
||||
.from('habit_completions')
|
||||
.delete()
|
||||
.eq('id', existing.id);
|
||||
if (deleteError) setError(deleteError.message);
|
||||
}
|
||||
} else {
|
||||
const { error: insertError } = await supabase
|
||||
.from('habit_completions')
|
||||
.insert({
|
||||
habit_id: habit.id,
|
||||
user_id: userId,
|
||||
completed_on: today,
|
||||
});
|
||||
if (insertError) setError(insertError.message);
|
||||
}
|
||||
|
||||
await loadHabit();
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const updateHabit = async (values: {
|
||||
name: string;
|
||||
targetDescription: string;
|
||||
color: string;
|
||||
}) => {
|
||||
if (!userId || !habit) throw new Error('Habit not found.');
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('habits')
|
||||
.update({
|
||||
name: values.name,
|
||||
target_description: values.targetDescription || null,
|
||||
color: values.color,
|
||||
})
|
||||
.eq('id', habit.id);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
await loadHabit();
|
||||
};
|
||||
|
||||
const deleteHabit = async () => {
|
||||
if (!habit) return;
|
||||
setBusy(true);
|
||||
const { error: deleteError } = await supabase
|
||||
.from('habits')
|
||||
.delete()
|
||||
.eq('id', habit.id);
|
||||
if (deleteError) {
|
||||
setError(deleteError.message);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
history.push('/home');
|
||||
};
|
||||
|
||||
if (sessionChecked && !userId) {
|
||||
return <Redirect to="/auth" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref="/home" />
|
||||
</IonButtons>
|
||||
<IonTitle>{habit?.name ?? 'Habit'}</IonTitle>
|
||||
<IonButtons slot="end">
|
||||
{habit && (
|
||||
<>
|
||||
<IonButton fill="clear" onClick={() => setShowEditModal(true)}>
|
||||
<IonIcon icon={createOutline} />
|
||||
</IonButton>
|
||||
<IonButton
|
||||
fill="clear"
|
||||
color="danger"
|
||||
onClick={() => setShowDeleteAlert(true)}
|
||||
>
|
||||
<IonIcon icon={trashOutline} />
|
||||
</IonButton>
|
||||
</>
|
||||
)}
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent fullscreen>
|
||||
<div className="dashboard-shell ion-padding">
|
||||
{/* Loading state */}
|
||||
{loading ? (
|
||||
<>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '60%', height: '24px', borderRadius: '8px' }}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '120px',
|
||||
borderRadius: '24px',
|
||||
}}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '160px',
|
||||
borderRadius: '24px',
|
||||
}}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100px',
|
||||
borderRadius: '24px',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : error ? (
|
||||
/* Error state */
|
||||
<div className="inline-message error-card">
|
||||
<h3>Something went wrong</h3>
|
||||
<p>{error}</p>
|
||||
<IonButton onClick={loadHabit}>Try Again</IonButton>
|
||||
</div>
|
||||
) : !habit ? (
|
||||
/* Not found state */
|
||||
<div className="inline-message">
|
||||
<h3>Habit not found</h3>
|
||||
<p>
|
||||
This habit may have been deleted or you don't have access
|
||||
to it.
|
||||
</p>
|
||||
<IonButton routerLink="/home">Back to habits</IonButton>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Habit info card */}
|
||||
<section className="hero-card compact">
|
||||
<div className="habit-copy-topline">
|
||||
<span
|
||||
className="habit-color-pill"
|
||||
style={{ background: habit.color }}
|
||||
/>
|
||||
<span className="eyebrow">Your routine</span>
|
||||
</div>
|
||||
<h1>{habit.name}</h1>
|
||||
<p>
|
||||
{habit.target_description ||
|
||||
'Show up today and keep the chain alive.'}
|
||||
</p>
|
||||
|
||||
{/* Today toggle */}
|
||||
<div style={{ marginTop: '18px' }}>
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill={doneToday ? 'solid' : 'outline'}
|
||||
onClick={toggleToday}
|
||||
disabled={busy}
|
||||
style={
|
||||
doneToday
|
||||
? ({
|
||||
'--background': habit.color,
|
||||
'--background-hover': habit.color,
|
||||
'--background-activated': habit.color,
|
||||
'--border-radius': '18px',
|
||||
minHeight: '50px',
|
||||
} as any)
|
||||
: ({
|
||||
'--border-color': habit.color,
|
||||
'--color': habit.color,
|
||||
'--border-radius': '18px',
|
||||
minHeight: '50px',
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<IonIcon
|
||||
icon={doneToday ? checkmarkOutline : ellipseOutline}
|
||||
slot="start"
|
||||
/>
|
||||
{doneToday ? 'Completed today' : "Mark today's habit done"}
|
||||
</IonButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* All-time stats */}
|
||||
<section>
|
||||
<p className="home-section-label">All-time stats</p>
|
||||
<div className="stack-list">
|
||||
{/* Stats row */}
|
||||
<div
|
||||
className="hero-card compact"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: '12px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '1.8rem',
|
||||
fontWeight: 800,
|
||||
color: habit.color,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{totalCompletions}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--ion-color-medium)',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
total done
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '1.8rem',
|
||||
fontWeight: 800,
|
||||
color: habit.color,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{longestStreak}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--ion-color-medium)',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
best streak
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '1.8rem',
|
||||
fontWeight: 800,
|
||||
color: habit.color,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{consistencyRate}%
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--ion-color-medium)',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
consistency
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current streak highlight */}
|
||||
<div
|
||||
className="hero-card compact"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '14px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '16px',
|
||||
background: `${habit.color}18`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={flameOutline}
|
||||
style={{ color: habit.color, fontSize: '1.3rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 700,
|
||||
color: 'var(--ion-color-dark)',
|
||||
}}
|
||||
>
|
||||
{currentStreak} day{currentStreak === 1 ? '' : 's'} and
|
||||
counting
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: '4px 0 0',
|
||||
color: 'var(--ion-color-medium)',
|
||||
fontSize: '0.84rem',
|
||||
}}
|
||||
>
|
||||
{currentStreak > 0
|
||||
? `You've shown up every day since ${(() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - currentStreak + 1);
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
})()}.`
|
||||
: "Start today and build a streak you're proud of."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 30-day heatmap */}
|
||||
<section>
|
||||
<p className="home-section-label">Last 30 days</p>
|
||||
<div className="hero-card compact">
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{heatmapDays.map((day) => (
|
||||
<div
|
||||
key={day.key}
|
||||
title={
|
||||
day.isToday
|
||||
? 'Today'
|
||||
: new Date(day.key).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
style={{
|
||||
aspectRatio: '1',
|
||||
borderRadius: '6px',
|
||||
background: day.completed
|
||||
? habit.color
|
||||
: `${habit.color}14`,
|
||||
border: day.isToday
|
||||
? `2px solid ${habit.color}`
|
||||
: '2px solid transparent',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: '12px',
|
||||
fontSize: '0.74rem',
|
||||
color: 'var(--ion-color-medium)',
|
||||
}}
|
||||
>
|
||||
<span>30 days ago</span>
|
||||
<span>
|
||||
<IonIcon
|
||||
icon={trendingUpOutline}
|
||||
style={{ verticalAlign: 'middle', marginRight: '4px' }}
|
||||
/>
|
||||
{heatmapDays.filter((d) => d.completed).length} of 30 days
|
||||
</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Edit modal */}
|
||||
{showEditModal && (
|
||||
<HabitFormModal
|
||||
isOpen={showEditModal}
|
||||
onDismiss={() => setShowEditModal(false)}
|
||||
onSubmit={updateHabit}
|
||||
initialValues={{
|
||||
name: habit.name,
|
||||
targetDescription: habit.target_description ?? '',
|
||||
color: habit.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<IonAlert
|
||||
isOpen={showDeleteAlert}
|
||||
onDidDismiss={() => setShowDeleteAlert(false)}
|
||||
header="Delete habit?"
|
||||
message={`Are you sure you want to delete "${habit?.name ?? ''}"? This will also remove all its completion history. This action cannot be undone.`}
|
||||
buttons={[
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
role: 'destructive',
|
||||
handler: deleteHabit,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default HabitDetail;
|
||||
@@ -1,490 +1,467 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import {
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonPage,
|
||||
IonRefresher,
|
||||
IonRefresherContent,
|
||||
IonSkeletonText,
|
||||
IonToolbar,
|
||||
IonContent,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
IonIcon,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
addOutline,
|
||||
analyticsOutline,
|
||||
checkmarkCircle,
|
||||
personCircleOutline,
|
||||
sparklesOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { PushNotifications } from '@capacitor/push-notifications';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Redirect, useHistory } from 'react-router-dom';
|
||||
import type { Tables } from '../database.types';
|
||||
import { documentTextOutline, addOutline, searchOutline } from 'ionicons/icons';
|
||||
import { supabase } from '../supabase';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import HabitCard from '../components/HabitCard';
|
||||
import HabitFormModal from '../components/HabitFormModal';
|
||||
import { useProfile } from '../contexts/ProfileContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import DashboardMetricCard from '../components/DashboardMetricCard';
|
||||
import RecentActivityCard from '../components/RecentActivityCard';
|
||||
import { Database } from '../database.types';
|
||||
|
||||
interface HabitWithMeta extends Tables<'habits'> {
|
||||
completions: Tables<'habit_completions'>[];
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||
|
||||
const calculateCurrentStreak = (dates: string[]) => {
|
||||
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
|
||||
let streak = 0;
|
||||
let cursor = new Date();
|
||||
|
||||
if (!unique.includes(formatDate(cursor))) {
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
|
||||
while (unique.includes(formatDate(cursor))) {
|
||||
streak += 1;
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
|
||||
return streak;
|
||||
};
|
||||
type RfqRow = Database['public']['Tables']['rfqs']['Row'];
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
|
||||
const { user } = useAuth();
|
||||
const { profile } = useProfile();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [busyHabitId, setBusyHabitId] = useState<string | null>(null);
|
||||
const pushTokenRef = useRef<string | null>(null);
|
||||
const pushListenersReadyRef = useRef(false);
|
||||
|
||||
const showError = (message: string) => {
|
||||
setError(message);
|
||||
window.setTimeout(() => setError(''), 4000);
|
||||
};
|
||||
const [rfqSummary, setRfqSummary] = useState({
|
||||
open: 0,
|
||||
quoted: 0,
|
||||
closed: 0,
|
||||
});
|
||||
const [supplierSummary, setSupplierSummary] = useState({
|
||||
assignedOpen: 0,
|
||||
myQuotes: 0,
|
||||
accepted: 0,
|
||||
});
|
||||
const [recentItems, setRecentItems] = useState<any[]>([]);
|
||||
|
||||
const registerPushNotifications = useCallback(
|
||||
async (currentUserId: string) => {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return;
|
||||
}
|
||||
useIonViewWillEnter(() => {
|
||||
loadDashboardData();
|
||||
});
|
||||
|
||||
if (!pushListenersReadyRef.current) {
|
||||
pushListenersReadyRef.current = true;
|
||||
|
||||
await PushNotifications.removeAllListeners();
|
||||
|
||||
PushNotifications.addListener('registration', async (token) => {
|
||||
if (pushTokenRef.current === token.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pushTokenRef.current = token.value;
|
||||
const { error: updateError } = await supabase
|
||||
.from('habits')
|
||||
.update({ fcm_token: token.value })
|
||||
.eq('user_id', currentUserId);
|
||||
|
||||
if (updateError) {
|
||||
showError(updateError.message);
|
||||
}
|
||||
});
|
||||
|
||||
PushNotifications.addListener(
|
||||
'registrationError',
|
||||
(registrationError) => {
|
||||
showError(registrationError.error ?? 'Push registration failed.');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const permissionStatus = await PushNotifications.checkPermissions();
|
||||
if (permissionStatus.receive === 'prompt') {
|
||||
const requested = await PushNotifications.requestPermissions();
|
||||
if (requested.receive !== 'granted') {
|
||||
return;
|
||||
}
|
||||
} else if (permissionStatus.receive !== 'granted') {
|
||||
return;
|
||||
}
|
||||
|
||||
await PushNotifications.register();
|
||||
},
|
||||
[showError]
|
||||
);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
const loadDashboardData = async () => {
|
||||
if (!user || !profile) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const {
|
||||
data: { session },
|
||||
error: sessionError,
|
||||
} = await supabase.auth.getSession();
|
||||
try {
|
||||
if (profile.role === 'buyer') {
|
||||
// Aggregate counts
|
||||
const { data: allRfqs, error: countErr } = await supabase
|
||||
.from('rfqs')
|
||||
.select('id, status')
|
||||
.eq('buyer_id', user.id);
|
||||
|
||||
if (sessionError) {
|
||||
setError(sessionError.message);
|
||||
setSessionChecked(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (countErr) throw countErr;
|
||||
|
||||
if (!session?.user) {
|
||||
setUserId(null);
|
||||
setSessionChecked(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setUserId(session.user.id);
|
||||
setSessionChecked(true);
|
||||
await registerPushNotifications(session.user.id);
|
||||
|
||||
const { data, error: habitsError } = await supabase
|
||||
.from('habits')
|
||||
.select('*, completions:habit_completions(*)')
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (habitsError) {
|
||||
setError(habitsError.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setHabits((data ?? []) as HabitWithMeta[]);
|
||||
setLoading(false);
|
||||
}, [registerPushNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
}, [loadDashboard]);
|
||||
|
||||
const today = formatDate(new Date());
|
||||
|
||||
const habitStats = useMemo(() => {
|
||||
const last7Days = Array.from({ length: 7 }, (_, index) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - index);
|
||||
return formatDate(date);
|
||||
});
|
||||
|
||||
return habits.reduce<
|
||||
Record<string, { streak: number; weekRate: number; doneToday: boolean }>
|
||||
>((acc, habit) => {
|
||||
const completionDates = habit.completions.map(
|
||||
(entry) => entry.completed_on
|
||||
);
|
||||
const completedInWeek = completionDates.filter((date) =>
|
||||
last7Days.includes(date)
|
||||
).length;
|
||||
|
||||
acc[habit.id] = {
|
||||
streak: calculateCurrentStreak(completionDates),
|
||||
weekRate: Math.round((completedInWeek / last7Days.length) * 100),
|
||||
doneToday: completionDates.includes(today),
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}, [habits, today]);
|
||||
|
||||
const totalCompletedToday = habits.filter(
|
||||
(habit) => habitStats[habit.id]?.doneToday
|
||||
).length;
|
||||
const longestStreak = habits.reduce(
|
||||
(max, habit) => Math.max(max, habitStats[habit.id]?.streak ?? 0),
|
||||
0
|
||||
);
|
||||
const completionRatio = habits.length
|
||||
? totalCompletedToday / habits.length
|
||||
: 0;
|
||||
const pendingCount = Math.max(habits.length - totalCompletedToday, 0);
|
||||
|
||||
const createHabit = async (values: {
|
||||
name: string;
|
||||
targetDescription: string;
|
||||
color: string;
|
||||
}) => {
|
||||
if (!userId) throw new Error('You need to be logged in.');
|
||||
|
||||
const { error: insertError } = await supabase.from('habits').insert({
|
||||
user_id: userId,
|
||||
name: values.name,
|
||||
target_description: values.targetDescription || null,
|
||||
color: values.color,
|
||||
});
|
||||
|
||||
if (insertError) throw insertError;
|
||||
await loadDashboard();
|
||||
};
|
||||
|
||||
const toggleHabit = async (habit: HabitWithMeta) => {
|
||||
if (!userId) return;
|
||||
setBusyHabitId(habit.id);
|
||||
|
||||
const existing = habit.completions.find(
|
||||
(entry) => entry.completed_on === today
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
const { error: deleteError } = await supabase
|
||||
.from('habit_completions')
|
||||
.delete()
|
||||
.eq('id', existing.id);
|
||||
if (deleteError) showError(deleteError.message);
|
||||
} else {
|
||||
const { error: insertError } = await supabase
|
||||
.from('habit_completions')
|
||||
.insert({
|
||||
habit_id: habit.id,
|
||||
user_id: userId,
|
||||
completed_on: today,
|
||||
let open = 0,
|
||||
quoted = 0,
|
||||
closed = 0;
|
||||
allRfqs?.forEach((r) => {
|
||||
if (r.status === 'open') open++;
|
||||
else if (r.status === 'quoted') quoted++;
|
||||
else if (r.status === 'closed') closed++;
|
||||
});
|
||||
if (insertError) {
|
||||
showError(insertError.message);
|
||||
setRfqSummary({ open, quoted, closed });
|
||||
|
||||
// Recent items
|
||||
const { data: recent, error: recentErr } = await supabase
|
||||
.from('rfqs')
|
||||
.select('*')
|
||||
.eq('buyer_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5);
|
||||
if (recentErr) throw recentErr;
|
||||
setRecentItems(recent || []);
|
||||
} else {
|
||||
const { error: pushError } = await supabase.functions.invoke(
|
||||
'send-habit-completion-push',
|
||||
{
|
||||
body: { habitId: habit.id },
|
||||
}
|
||||
);
|
||||
// Supplier
|
||||
const { data: allRecipients, error: recErr } = await supabase
|
||||
.from('rfq_recipients')
|
||||
.select('id, rfqs!inner(id, status)')
|
||||
.eq('supplier_id', user.id);
|
||||
if (recErr) throw recErr;
|
||||
|
||||
if (pushError) {
|
||||
showError(pushError.message);
|
||||
}
|
||||
let assignedOpen = 0;
|
||||
allRecipients?.forEach((r: any) => {
|
||||
if (r.rfqs && r.rfqs.status === 'open') assignedOpen++;
|
||||
});
|
||||
|
||||
const { data: myQuotes, error: qErr } = await supabase
|
||||
.from('quotations')
|
||||
.select('id, status')
|
||||
.eq('supplier_id', user.id);
|
||||
if (qErr) throw qErr;
|
||||
|
||||
let accepted = 0;
|
||||
myQuotes?.forEach((q) => {
|
||||
if (q.status === 'accepted') accepted++;
|
||||
});
|
||||
setSupplierSummary({
|
||||
assignedOpen,
|
||||
myQuotes: myQuotes?.length || 0,
|
||||
accepted,
|
||||
});
|
||||
|
||||
const { data: recent, error: recentErr } = await supabase
|
||||
.from('rfq_recipients')
|
||||
.select('id, rfqs!inner(*)')
|
||||
.eq('supplier_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5);
|
||||
if (recentErr) throw recentErr;
|
||||
|
||||
// Map joined data
|
||||
const mapped = (recent || []).map((r: any) => r.rfqs);
|
||||
setRecentItems(mapped);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError('Failed to load dashboard data.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
await loadDashboard();
|
||||
setBusyHabitId(null);
|
||||
};
|
||||
|
||||
const deleteHabit = async (habitId: string) => {
|
||||
setBusyHabitId(habitId);
|
||||
const { error: deleteError } = await supabase
|
||||
.from('habits')
|
||||
.delete()
|
||||
.eq('id', habitId);
|
||||
if (deleteError) {
|
||||
showError(deleteError.message);
|
||||
const handlePrimaryAction = () => {
|
||||
if (profile?.role === 'buyer') {
|
||||
history.push('/rfq/new');
|
||||
} else {
|
||||
history.push('/requests');
|
||||
}
|
||||
await loadDashboard();
|
||||
setBusyHabitId(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
void PushNotifications.removeAllListeners();
|
||||
};
|
||||
}, []);
|
||||
const handleRecentItemTap = (item: RfqRow) => {
|
||||
history.push(`/rfq/${item.id}`);
|
||||
};
|
||||
|
||||
if (sessionChecked && !userId) {
|
||||
return <Redirect to="/auth" />;
|
||||
}
|
||||
if (!profile) return null;
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border home-header">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="end">
|
||||
<IonButton
|
||||
className="soft-icon-button"
|
||||
onClick={() => history.push('/stats')}
|
||||
>
|
||||
<IonIcon icon={analyticsOutline} />
|
||||
</IonButton>
|
||||
<IonButton
|
||||
className="soft-icon-button"
|
||||
onClick={() => history.push('/account')}
|
||||
>
|
||||
<IonIcon icon={personCircleOutline} />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent fullscreen className="home-content">
|
||||
<IonRefresher
|
||||
slot="fixed"
|
||||
onIonRefresh={async (e) => {
|
||||
await loadDashboard();
|
||||
e.detail.complete();
|
||||
<IonPage style={{ background: 'var(--color-bg)' }}>
|
||||
<IonContent
|
||||
style={{
|
||||
'--background': 'var(--color-bg-gradient)',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': '0px',
|
||||
'--padding-bottom': 'calc(96px + var(--ion-safe-area-bottom))',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: 'calc(16px + var(--ion-safe-area-top, 0px)) 20px 16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<IonRefresherContent />
|
||||
</IonRefresher>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '3px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '700',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--color-brand)',
|
||||
}}
|
||||
>
|
||||
QuoteLink
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
}}
|
||||
>
|
||||
Procurement made simpler
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => history.push('/profile')}
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '999px',
|
||||
background: 'var(--color-brand-medium)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--color-brand)',
|
||||
fontSize: '16px',
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{profile.full_name.charAt(0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-shell home-shell ion-padding">
|
||||
<section className="habits-hero">
|
||||
<div className="habits-hero-top">
|
||||
<div>
|
||||
<p className="hero-kicker">Habits ({habits.length})</p>
|
||||
<div className="hero-title-wrap">
|
||||
<h1>Daily</h1>
|
||||
<span>Weekly</span>
|
||||
</div>
|
||||
</div>
|
||||
<IonButton
|
||||
className="hero-add-button"
|
||||
fill="clear"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
<div
|
||||
style={{
|
||||
margin: '0 20px 18px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '5px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '26px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: '1.08',
|
||||
}}
|
||||
>
|
||||
Good to see you,{' '}
|
||||
<span style={{ fontWeight: '700' }}>
|
||||
{profile.full_name.split(' ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{profile.role === 'buyer'
|
||||
? 'Track requests, review supplier pricing, and keep decisions moving.'
|
||||
: 'Review assigned RFQs, send pricing fast, and stay on top of wins.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
margin: '0 20px 12px',
|
||||
background: 'var(--color-danger-soft)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 14px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: 'var(--color-danger-text)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-brand-strong)',
|
||||
borderRadius: '24px',
|
||||
padding: '22px',
|
||||
margin: '0 20px 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '14px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={handlePrimaryAction}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: 'rgba(255,255,255,0.72)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
<IonIcon icon={addOutline} />
|
||||
</IonButton>
|
||||
</div>
|
||||
|
||||
<div className="hero-progress-track" aria-hidden="true">
|
||||
<div
|
||||
className="hero-progress-fill"
|
||||
style={{ width: `${Math.max(completionRatio * 100, 6)}%` }}
|
||||
{profile.role === 'buyer' ? 'Active Requests' : 'Assigned RFQs'}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '34px',
|
||||
fontWeight: '400',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<span className="hero-progress-dot" />
|
||||
</div>
|
||||
{profile.role === 'buyer'
|
||||
? rfqSummary.open + rfqSummary.quoted
|
||||
: supplierSummary.assignedOpen}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="hero-progress-meta">
|
||||
<span>{totalCompletedToday} completed today</span>
|
||||
<span>{Math.round(completionRatio * 100)}% completed</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading ? (
|
||||
<div className="home-routine-panel">
|
||||
<div className="stack-list home-habit-stack">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
className="habit-card habit-card--editorial habit-card-skeleton"
|
||||
key={`habit-skeleton-${index}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-check"
|
||||
/>
|
||||
|
||||
<div className="habit-card-main">
|
||||
<div className="habit-copy">
|
||||
<div className="habit-copy-topline">
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-title"
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-color-pill"
|
||||
/>
|
||||
</div>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-description"
|
||||
/>
|
||||
<div className="habit-metrics-row">
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-metric"
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-metric short"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-action"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="inline-message error-card home-inline-card">
|
||||
<h3>Couldn't load your habits</h3>
|
||||
<p>{error}</p>
|
||||
<IonButton onClick={loadDashboard}>Try Again</IonButton>
|
||||
</div>
|
||||
) : habits.length === 0 ? (
|
||||
<div className="home-routine-panel">
|
||||
<EmptyState
|
||||
title="Start your first routine"
|
||||
message="Create one small daily habit and begin building your streak story today."
|
||||
actionLabel="Create First Habit"
|
||||
onAction={() => setShowCreateModal(true)}
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '14px',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={profile.role === 'buyer' ? addOutline : searchOutline}
|
||||
style={{ color: '#ffffff', fontSize: '24px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-accent)',
|
||||
}}
|
||||
>
|
||||
{profile.role === 'buyer'
|
||||
? 'Create new request →'
|
||||
: 'Browse requests →'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', margin: '0 20px 24px' }}>
|
||||
{profile.role === 'buyer' ? (
|
||||
<>
|
||||
<DashboardMetricCard
|
||||
label="Received Quotes"
|
||||
value={rfqSummary.quoted.toString()}
|
||||
hint="Awaiting decision"
|
||||
icon={documentTextOutline}
|
||||
/>
|
||||
<DashboardMetricCard
|
||||
label="Completed"
|
||||
value={rfqSummary.closed.toString()}
|
||||
hint="Past requests"
|
||||
icon={documentTextOutline}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<section className="home-routine-panel">
|
||||
<div className="stack-list home-habit-stack">
|
||||
{habits.map((habit) => (
|
||||
<HabitCard
|
||||
key={habit.id}
|
||||
habit={habit}
|
||||
completedToday={habitStats[habit.id]?.doneToday ?? false}
|
||||
currentStreak={habitStats[habit.id]?.streak ?? 0}
|
||||
completionRate={habitStats[habit.id]?.weekRate ?? 0}
|
||||
onToggle={() => toggleHabit(habit)}
|
||||
onDelete={() => deleteHabit(habit.id)}
|
||||
busy={busyHabitId === habit.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="home-complete-section">
|
||||
<p className="home-section-label">Momentum</p>
|
||||
<div className="home-summary-card">
|
||||
<div
|
||||
className="summary-ring"
|
||||
style={{
|
||||
background: `conic-gradient(#7c3aed 0deg, #a855f7 ${completionRatio * 360}deg, rgba(109, 40, 217, 0.08) ${completionRatio * 360}deg 360deg)`,
|
||||
}}
|
||||
>
|
||||
<div className="summary-ring-inner">
|
||||
{Math.round(completionRatio * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-copy">
|
||||
<h3>Keep your rhythm</h3>
|
||||
<p>
|
||||
{pendingCount === 0
|
||||
? 'Everything is complete today. Enjoy the streak.'
|
||||
: `${pendingCount} habit${pendingCount === 1 ? '' : 's'} left to protect today's routine.`}
|
||||
</p>
|
||||
<div className="summary-metrics">
|
||||
<span>
|
||||
<IonIcon icon={sparklesOutline} /> {totalCompletedToday}{' '}
|
||||
done
|
||||
</span>
|
||||
<span>
|
||||
<IonIcon icon={checkmarkCircle} /> {longestStreak} day
|
||||
best streak
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<DashboardMetricCard
|
||||
label="My Quotes"
|
||||
value={supplierSummary.myQuotes.toString()}
|
||||
hint="Submitted"
|
||||
icon={documentTextOutline}
|
||||
/>
|
||||
<DashboardMetricCard
|
||||
label="Accepted"
|
||||
value={supplierSummary.accepted.toString()}
|
||||
hint="Won bids"
|
||||
icon={documentTextOutline}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<HabitFormModal
|
||||
isOpen={showCreateModal}
|
||||
onDismiss={() => setShowCreateModal(false)}
|
||||
onSubmit={createHabit}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
margin: '16px 20px 8px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '700',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
Recent Activity
|
||||
</div>
|
||||
<div
|
||||
onClick={() => history.push('/requests')}
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-brand)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
See all
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
margin: '0 20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<IonSpinner
|
||||
name="crescent"
|
||||
style={{ color: 'var(--color-brand)' }}
|
||||
/>
|
||||
</div>
|
||||
) : recentItems.length > 0 ? (
|
||||
recentItems.map((item) => (
|
||||
<RecentActivityCard
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
subtitle={new Date(item.created_at).toLocaleDateString()}
|
||||
meta={item.status}
|
||||
onClick={() => handleRecentItemTap(item)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
margin: '0 20px',
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: '24px',
|
||||
padding: '28px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={documentTextOutline}
|
||||
style={{ fontSize: '48px', color: 'var(--color-text-tertiary)' }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
No recent activity yet.
|
||||
</div>
|
||||
{profile.role === 'buyer' && (
|
||||
<button
|
||||
onClick={handlePrimaryAction}
|
||||
style={{
|
||||
background: 'var(--color-brand)',
|
||||
color: '#ffffff',
|
||||
borderRadius: '999px',
|
||||
padding: '10px 20px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
Create Request
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import {
|
||||
IonPage,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonButtons,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonTitle,
|
||||
IonContent,
|
||||
IonSpinner,
|
||||
} from '@ionic/react';
|
||||
import { chevronBackOutline, cameraOutline, closeCircle } from 'ionicons/icons';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import SupplierPickerRow, {
|
||||
SupplierOption,
|
||||
} from '../components/SupplierPickerRow';
|
||||
|
||||
const NewRfq: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [requestText, setRequestText] = useState('');
|
||||
|
||||
const [supplierOptions, setSupplierOptions] = useState<SupplierOption[]>([]);
|
||||
const [selectedSupplierIds, setSelectedSupplierIds] = useState<string[]>([]);
|
||||
const [loadingSuppliers, setLoadingSuppliers] = useState(true);
|
||||
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadSuppliers();
|
||||
return () => {
|
||||
cleanupPreview();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadSuppliers = async () => {
|
||||
setLoadingSuppliers(true);
|
||||
const { data, error: err } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, full_name, company_name, city')
|
||||
.eq('role', 'supplier')
|
||||
.order('company_name', { ascending: true })
|
||||
.order('full_name', { ascending: true });
|
||||
|
||||
if (!err && data) {
|
||||
setSupplierOptions(data);
|
||||
}
|
||||
setLoadingSuppliers(false);
|
||||
};
|
||||
|
||||
const cleanupPreview = () => {
|
||||
if (imagePreviewUrl) {
|
||||
URL.revokeObjectURL(imagePreviewUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const file = e.target.files[0];
|
||||
setImageFile(file);
|
||||
cleanupPreview();
|
||||
setImagePreviewUrl(URL.createObjectURL(file));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSupplier = (id: string) => {
|
||||
if (selectedSupplierIds.includes(id)) {
|
||||
setSelectedSupplierIds(selectedSupplierIds.filter((s) => s !== id));
|
||||
} else {
|
||||
if (selectedSupplierIds.length >= 5) {
|
||||
showError('Select up to 5 suppliers');
|
||||
} else {
|
||||
setSelectedSupplierIds([...selectedSupplierIds, id]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setError(msg);
|
||||
setTimeout(() => setError(''), 4000);
|
||||
};
|
||||
|
||||
const handleCreateRfq = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
if (!requestText.trim()) {
|
||||
showError('Please describe your request.');
|
||||
return;
|
||||
}
|
||||
if (selectedSupplierIds.length === 0) {
|
||||
showError('Please select at least 1 supplier.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// 1. Get Reference Number
|
||||
const { data: refData, error: refError } =
|
||||
await supabase.functions.invoke('create-rfq-reference', {
|
||||
body: {},
|
||||
});
|
||||
if (refError || !refData?.referenceNumber) {
|
||||
throw new Error('Failed to generate reference number.');
|
||||
}
|
||||
|
||||
const referenceNumber = refData.referenceNumber;
|
||||
const derivedTitle =
|
||||
title.trim() || requestText.trim().substring(0, 30) + '...';
|
||||
|
||||
// 2. Insert RFQ
|
||||
const { data: rfq, error: rfqError } = await supabase
|
||||
.from('rfqs')
|
||||
.insert({
|
||||
buyer_id: user.id,
|
||||
reference_number: referenceNumber,
|
||||
title: derivedTitle,
|
||||
request_text: requestText.trim(),
|
||||
status: 'open',
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (rfqError) throw rfqError;
|
||||
|
||||
// 3. Upload Image if present
|
||||
if (imageFile) {
|
||||
const filePath = `${user.id}/${rfq.id}/${Date.now()}-${imageFile.name}`;
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('rfq-images')
|
||||
.upload(filePath, imageFile);
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
const { error: imgMetaError } = await supabase
|
||||
.from('rfq_images')
|
||||
.insert({
|
||||
rfq_id: rfq.id,
|
||||
owner_id: user.id,
|
||||
storage_path: filePath,
|
||||
});
|
||||
|
||||
if (imgMetaError) throw imgMetaError;
|
||||
}
|
||||
|
||||
// 4. Insert Recipients
|
||||
const recipientsToInsert = selectedSupplierIds.map((sid) => ({
|
||||
rfq_id: rfq.id,
|
||||
supplier_id: sid,
|
||||
}));
|
||||
const { error: recError } = await supabase
|
||||
.from('rfq_recipients')
|
||||
.insert(recipientsToInsert);
|
||||
|
||||
if (recError) throw recError;
|
||||
|
||||
// 5. Navigate
|
||||
history.replace(`/rfq/${rfq.id}`);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
showError(err.message || 'Failed to create RFQ.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage style={{ background: '#f7faf7' }}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': '#f7faf7' }}>
|
||||
<IonButtons slot="start">
|
||||
<IonButton
|
||||
onClick={() => history.goBack()}
|
||||
style={{ color: '#14532d' }}
|
||||
>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle
|
||||
style={{ fontSize: '18px', fontWeight: '700', color: '#0f1720' }}
|
||||
>
|
||||
New Request
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent
|
||||
style={{
|
||||
'--background': '#f7faf7',
|
||||
'--padding-start': '20px',
|
||||
'--padding-end': '20px',
|
||||
'--padding-top': '20px',
|
||||
'--padding-bottom': 'calc(28px + var(--ion-safe-area-bottom))',
|
||||
}}
|
||||
>
|
||||
<div style={{ margin: '0 0 16px', background: 'transparent' }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '26px',
|
||||
fontWeight: '700',
|
||||
color: '#0f1720',
|
||||
margin: '0 0 4px 0',
|
||||
}}
|
||||
>
|
||||
Request Quote
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: '#5f6d63',
|
||||
margin: '0',
|
||||
}}
|
||||
>
|
||||
Describe what you need and select suppliers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(239, 68, 68, 0.10)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 14px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: '#b91c1c',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleCreateRfq}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '18px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title (e.g. 100 Bricks + Cement)"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
style={{
|
||||
background: '#f4f7f4',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '400',
|
||||
color: '#0f1720',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Describe your request..."
|
||||
value={requestText}
|
||||
onChange={(e) => setRequestText(e.target.value)}
|
||||
rows={4}
|
||||
style={{
|
||||
background: '#f4f7f4',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '14px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '400',
|
||||
color: '#0f1720',
|
||||
width: '100%',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<label
|
||||
style={{ fontSize: '13px', fontWeight: '600', color: '#0f1720' }}
|
||||
>
|
||||
Site Photo or Reference (Optional)
|
||||
</label>
|
||||
{imagePreviewUrl ? (
|
||||
<div style={{ position: 'relative', width: 'max-content' }}>
|
||||
<img
|
||||
src={imagePreviewUrl}
|
||||
alt="Preview"
|
||||
style={{
|
||||
width: '88px',
|
||||
height: '88px',
|
||||
borderRadius: '12px',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<IonIcon
|
||||
icon={closeCircle}
|
||||
onClick={() => {
|
||||
setImageFile(null);
|
||||
setImagePreviewUrl(null);
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
fontSize: '24px',
|
||||
color: '#b91c1c',
|
||||
cursor: 'pointer',
|
||||
background: '#fff',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<label
|
||||
style={{
|
||||
background: '#edf4ee',
|
||||
borderRadius: '24px',
|
||||
padding: '18px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(22,101,52,0.12)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={cameraOutline}
|
||||
style={{ color: '#166534', fontSize: '24px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#166534',
|
||||
}}
|
||||
>
|
||||
Add a photo
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '400',
|
||||
color: '#6c7a71',
|
||||
}}
|
||||
>
|
||||
Tap to browse
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#0f1720',
|
||||
}}
|
||||
>
|
||||
Select Suppliers (Up to 5)
|
||||
</label>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '400',
|
||||
color: '#6c7a71',
|
||||
}}
|
||||
>
|
||||
{selectedSupplierIds.length}/5 selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: '#f9fbf9',
|
||||
borderRadius: '12px',
|
||||
padding: '14px',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{loadingSuppliers ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '10px',
|
||||
}}
|
||||
>
|
||||
<IonSpinner name="crescent" style={{ color: '#166534' }} />
|
||||
</div>
|
||||
) : supplierOptions.length > 0 ? (
|
||||
supplierOptions.map((supplier) => (
|
||||
<SupplierPickerRow
|
||||
key={supplier.id}
|
||||
supplier={supplier}
|
||||
selected={selectedSupplierIds.includes(supplier.id)}
|
||||
onToggle={() => toggleSupplier(supplier.id)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#6c7a71',
|
||||
textAlign: 'center',
|
||||
padding: '10px',
|
||||
}}
|
||||
>
|
||||
No suppliers found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || selectedSupplierIds.length === 0}
|
||||
style={{
|
||||
background: '#166534',
|
||||
color: '#ffffff',
|
||||
borderRadius: '999px',
|
||||
padding: '16px 20px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '700',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
opacity: loading || selectedSupplierIds.length === 0 ? 0.7 : 1,
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<IonSpinner
|
||||
name="crescent"
|
||||
style={{ color: '#fff', width: '20px', height: '20px' }}
|
||||
/>
|
||||
) : (
|
||||
'Send Request'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewRfq;
|
||||