Compare commits

..

1 Commits

Author SHA1 Message Date
AppCakes 0119c17e34 build: 91279040-7fca-48e0-9d50-5ce29aae63f6 2026-06-19 06:45:54 +00:00
130 changed files with 7071 additions and 6333 deletions
Binary file not shown.
+2 -2
View File
@@ -4,10 +4,10 @@ android {
namespace = "io.ionic.starter"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.qoutelink.app"
applicationId "com.inspot.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 3
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [googleMapsApiKey: System.getenv("VITE_GOOGLE_MAPS_KEY") ?: ""]
+12 -1
View File
@@ -1,6 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
@@ -25,9 +30,15 @@
<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" />
<data android:scheme="com.inspot.app" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.inspot.app" />
</intent-filter>
</activity>
<meta-data
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="splashBackground">#FFFFFF</color>
</resources>
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Qoutelink</string>
<string name="title_activity_main">Qoutelink</string>
<string name="app_name">InSpot</string>
<string name="title_activity_main">InSpot</string>
<string name="package_name">io.ionic.starter</string>
<string name="custom_url_scheme">io.ionic.starter</string>
</resources>
+2 -4
View File
@@ -16,9 +16,7 @@
</style>
<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 name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
+2 -2
View File
@@ -1,8 +1,8 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.qoutelink.app',
appName: 'Qoutelink',
appId: 'com.inspot.app',
appName: 'InSpot',
webDir: 'dist',
plugins: {
FirebaseAuthentication: {
+4 -4
View File
@@ -299,7 +299,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
CURRENT_PROJECT_VERSION = 1;
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.qoutelink.app;
PRODUCT_BUNDLE_IDENTIFIER = com.inspot.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 = 3;
CURRENT_PROJECT_VERSION = 1;
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.qoutelink.app;
PRODUCT_BUNDLE_IDENTIFIER = com.inspot.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
+18 -1
View File
@@ -1,5 +1,7 @@
import UIKit
import Capacitor
import FirebaseCore
import FirebaseMessaging
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
@@ -7,10 +9,25 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
FirebaseApp.configure()
return true
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
Messaging.messaging().token { token, error in
if let error = error {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
} else if let token = token {
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: token)
}
}
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 890 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 B

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 979 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+17 -7
View File
@@ -1,13 +1,23 @@
{
"images": [
"images" : [
{
"idiom": "universal",
"filename": "splash.png",
"scale": "1x"
"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"
}
],
"info": {
"author": "appsuite",
"version": 1
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 480 KiB

+22 -7
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Qoutelink</string>
<string>InSpot</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -47,20 +47,35 @@
</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>
<string>com.inspot.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.qoutelink.app</string>
<string>com.inspot.app</string>
</array>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.inspot.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.inspot.app</string>
</array>
</dict>
</array>
<key>NSCameraUsageDescription</key>
<string>InSpot needs camera access to let you capture and share reels of local spots.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>InSpot needs photo library access to let you upload profile photos and share reels from your gallery.</string>
<key>NSMicrophoneUsageDescription</key>
<string>InSpot needs microphone access to record sound for your reels.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>InSpot needs your location to discover nearby spots and social connections.</string>
</dict>
</plist>
+1 -1
View File
@@ -18,7 +18,6 @@
"@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",
"@hookform/resolvers": "^3.9.0",
@@ -29,6 +28,7 @@
"firebase": "^11.0.0",
"ionicons": "^7.4.0",
"katex": "^0.16.0",
"lottie-react": "^2.4.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.54.0",
+129 -32
View File
@@ -1,16 +1,24 @@
import React, { useEffect } from 'react';
import { IonApp } from '@ionic/react';
import React from 'react';
import { Redirect, Route } from 'react-router-dom';
import {
IonApp,
IonIcon,
IonLabel,
IonRouterOutlet,
IonTabBar,
IonTabButton,
IonTabs,
} from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router';
import { AuthProvider } from './contexts/AuthContext';
import { ProfileProvider, useProfile } from './contexts/ProfileContext';
import AppRoutes from './AppRoutes';
import { setStatusBarStyle, Style } from './utils/statusBar';
import {
flameOutline,
locationOutline,
chatbubblesOutline,
personOutline,
} from 'ionicons/icons';
/* 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';
@@ -26,31 +34,120 @@ import '@ionic/react/css/display.css';
/* Theme variables */
import './theme/variables.css';
const AppShell: React.FC = () => {
const { themeMode } = useProfile();
/* Pages */
import AuthPage from './pages/AuthPage';
import VerifyEmailPage from './pages/VerifyEmailPage';
import ForgotPasswordPage from './pages/ForgotPasswordPage';
import NewPasswordPage from './pages/NewPasswordPage';
import OnboardingProfilePage from './pages/OnboardingProfilePage';
import IcebreakerPage from './pages/IcebreakerPage';
import HangoutPage from './pages/HangoutPage';
import ReelsPage from './pages/ReelsPage';
import CameraPage from './pages/CameraPage';
import ChatsPage from './pages/ChatsPage';
import ChatThreadPage from './pages/ChatThreadPage';
import ProfilePage from './pages/ProfilePage';
import EditProfilePage from './pages/EditProfilePage';
import NotificationsPage from './pages/NotificationsPage';
import PremiumPage from './pages/PremiumPage';
useEffect(() => {
document.body.classList.toggle('dark', themeMode === 'dark');
setStatusBarStyle(themeMode === 'dark' ? Style.Dark : Style.Light);
}, [themeMode]);
/* Contexts & Components */
import { AuthProvider } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import { isPreviewMode } from './utils/previewMode';
return (
<IonReactRouter>
<AppRoutes />
</IonReactRouter>
);
};
const App: React.FC = () => (
<IonApp>
<AuthProvider>
<IonReactRouter>
<IonRouterOutlet>
{/* Auth Stack */}
<Route path="/auth" component={AuthPage} exact />
<Route path="/verify-email" component={VerifyEmailPage} exact />
<Route path="/forgot-password" component={ForgotPasswordPage} exact />
<Route path="/new-password" component={NewPasswordPage} exact />
<ProtectedRoute
path="/onboarding/profile"
component={OnboardingProfilePage}
exact
/>
const App: React.FC = () => {
return (
<IonApp>
<AuthProvider>
<ProfileProvider>
<AppShell />
</ProfileProvider>
</AuthProvider>
</IonApp>
);
};
{/* Detailed Stack (No Tabs) */}
<ProtectedRoute path="/reels/:placeId" component={ReelsPage} exact />
<ProtectedRoute
path="/chat/:conversationId"
component={ChatThreadPage}
exact
/>
<ProtectedRoute
path="/profile/edit"
component={EditProfilePage}
exact
/>
<ProtectedRoute path="/premium" component={PremiumPage} exact />
<ProtectedRoute
path="/notifications"
component={NotificationsPage}
exact
/>
<ProtectedRoute path="/camera" component={CameraPage} exact />
{/* Main Tabs */}
<Route
path={['/icebreaker', '/hangout', '/chats', '/profile']}
render={() => (
<IonTabs>
<IonRouterOutlet>
<ProtectedRoute
path="/icebreaker"
component={IcebreakerPage}
exact
/>
<ProtectedRoute
path="/hangout"
component={HangoutPage}
exact
/>
<ProtectedRoute path="/chats" component={ChatsPage} exact />
<ProtectedRoute
path="/profile"
component={ProfilePage}
exact
/>
</IonRouterOutlet>
<IonTabBar slot="bottom">
<IonTabButton tab="icebreaker" href="/icebreaker">
<IonIcon icon={flameOutline} />
<IonLabel>Icebreaker</IonLabel>
</IonTabButton>
<IonTabButton tab="hangout" href="/hangout">
<IonIcon icon={locationOutline} />
<IonLabel>Hangout</IonLabel>
</IonTabButton>
<IonTabButton tab="chats" href="/chats">
<IonIcon icon={chatbubblesOutline} />
<IonLabel>Chats</IonLabel>
</IonTabButton>
<IonTabButton tab="profile" href="/profile">
<IonIcon icon={personOutline} />
<IonLabel>Profile</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
)}
/>
<Route
exact
path="/"
render={() => (
<Redirect to={isPreviewMode() ? '/icebreaker' : '/auth'} />
)}
/>
</IonRouterOutlet>
</IonReactRouter>
</AuthProvider>
</IonApp>
);
export default App;
-105
View File
@@ -1,105 +0,0 @@
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;
View File
Binary file not shown.
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

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

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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

After

Width:  |  Height:  |  Size: 148 KiB

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

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

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

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+70
View File
@@ -0,0 +1,70 @@
import React from 'react';
import { getStorageUrl, getInitials, getInitialsColor } from '../utils/storage';
interface AvatarProps {
src?: string | null;
name?: string | null;
size?: number;
bucket?: string;
className?: string;
style?: React.CSSProperties;
}
const Avatar: React.FC<AvatarProps> = ({
src,
name,
size = 48,
bucket = 'avatars',
className = '',
style = {},
}) => {
const url =
src?.startsWith('/src/assets/') || src?.startsWith('/assets/')
? src
: getStorageUrl(bucket, src);
const containerStyle: React.CSSProperties = {
width: size,
height: size,
borderRadius: '50%',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
background: url ? 'transparent' : getInitialsColor(name || null),
...style,
};
if (url) {
return (
<div className={className} style={containerStyle}>
<img
src={url}
alt={name || 'Avatar'}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
);
}
return (
<div className={className} style={containerStyle}>
<span
style={{
color: '#ffffff',
fontSize: size * 0.4,
fontWeight: 700,
letterSpacing: '-0.02em',
}}
>
{getInitials(name || null)}
</span>
</div>
);
};
export default Avatar;
-97
View File
@@ -1,97 +0,0 @@
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;
+84
View File
@@ -0,0 +1,84 @@
import React from 'react';
import { IonButton, IonIcon } from '@ionic/react';
interface EmptyStateProps {
icon: string;
title: string;
message: string;
ctaText?: string;
onCtaClick?: () => void;
style?: React.CSSProperties;
}
const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
message,
ctaText,
onCtaClick,
style = {},
}) => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 20px',
textAlign: 'center',
minHeight: '300px',
...style,
}}
>
<div
style={{
width: '80px',
height: '80px',
borderRadius: '24px',
background: 'var(--color-surface-raised)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '20px',
}}
>
<IonIcon
icon={icon}
style={{ fontSize: '40px', color: 'var(--ion-color-primary)' }}
/>
</div>
<h3
style={{
fontSize: '20px',
fontWeight: 700,
margin: '0 0 8px',
color: 'var(--color-text-primary)',
}}
>
{title}
</h3>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: '0 0 24px',
lineHeight: 1.5,
}}
>
{message}
</p>
{ctaText && onCtaClick && (
<IonButton
expand="block"
onClick={onCtaClick}
style={{ '--border-radius': 'var(--radius-pill)', minWidth: '200px' }}
>
{ctaText}
</IonButton>
)}
</div>
);
};
export default EmptyState;
+59 -44
View File
@@ -1,34 +1,34 @@
import React, { useRef, useState } from 'react';
import { IonInput } from '@ionic/react';
import React, { useRef, useEffect } from 'react';
interface OtpInputProps {
value: string;
onChange: (value: string) => void;
length?: number;
disabled?: boolean;
}
const OtpInput: React.FC<OtpInputProps> = ({ value, onChange, disabled }) => {
const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
const OtpInput: React.FC<OtpInputProps> = ({
value,
onChange,
length = 6,
disabled = false,
}) => {
const inputRefs = 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: React.ChangeEvent<HTMLInputElement>,
index: number
) => {
const val = e.target.value;
if (!val) return;
const handleChange = (e: any, index: number) => {
let val = e.target.value;
// Allow only numeric
if (!/^[0-9]*$/.test(val)) return;
const newOtp = value.split('');
newOtp[index] = val.slice(-1);
const combined = newOtp.join('');
onChange(combined);
// 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();
if (val && index < length - 1) {
inputRefs.current[index + 1]?.focus();
}
};
@@ -36,42 +36,57 @@ const OtpInput: React.FC<OtpInputProps> = ({ value, onChange, disabled }) => {
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();
}
if (e.key === 'Backspace' && !value[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const pastedData = e.clipboardData.getData('text').slice(0, length);
if (/^\d+$/.test(pastedData)) {
onChange(pastedData);
inputRefs.current[Math.min(pastedData.length, length - 1)]?.focus();
}
};
return (
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
{digits.map((digit, index) => (
<div
style={{
display: 'flex',
gap: '8px',
justifyContent: 'center',
margin: '24px 0',
}}
>
{Array.from({ length }).map((_, i) => (
<input
key={index}
key={i}
ref={(el) => {
inputsRef.current[index] = el;
inputRefs.current[i] = el;
}}
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={2}
value={digit}
onChange={(e) => handleChange(e, index)}
onKeyDown={(e) => handleKeyDown(e, index)}
pattern="\d*"
maxLength={1}
value={value[i] || ''}
onChange={(e) => handleChange(e, i)}
onKeyDown={(e) => handleKeyDown(e, i)}
onPaste={handlePaste}
disabled={disabled}
style={{
width: '100%',
maxWidth: '48px',
width: '44px',
height: '52px',
borderRadius: '12px',
border: 'none',
background: '#f4f7f4',
color: '#0f1720',
textAlign: 'center',
fontSize: '24px',
fontWeight: '600',
fontSize: '22px',
fontWeight: 700,
borderRadius: '12px',
border: '2px solid transparent',
background: 'var(--color-surface)',
color: 'var(--color-text-primary)',
outline: 'none',
transition: 'border-color 0.2s',
...(value[i] ? { borderColor: 'var(--ion-color-primary)' } : {}),
}}
/>
))}
-92
View File
@@ -1,92 +0,0 @@
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;
+6 -36
View File
@@ -1,9 +1,7 @@
import React, { useEffect } from 'react';
import React 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';
import { isPreviewMode } from '../utils/previewMode';
interface ProtectedRouteProps extends RouteProps {
component: React.ComponentType<any>;
@@ -14,42 +12,14 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
...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]);
const previewMode = isPreviewMode();
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} />;
}}
render={(props) =>
user || previewMode ? <Component {...props} /> : <Redirect to="/auth" />
}
/>
);
};
-121
View File
@@ -1,121 +0,0 @@
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;
-90
View File
@@ -1,90 +0,0 @@
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;
-83
View File
@@ -1,83 +0,0 @@
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;
-135
View File
@@ -1,135 +0,0 @@
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;
+68
View File
@@ -0,0 +1,68 @@
import React from 'react';
import { IonSearchbar, IonButton, IonIcon } from '@ionic/react';
import { optionsOutline } from 'ionicons/icons';
interface SearchRowProps {
value: string;
onChange: (e: any) => void;
onFilterClick?: () => void;
placeholder?: string;
}
const SearchRow: React.FC<SearchRowProps> = ({
value,
onChange,
onFilterClick,
placeholder = 'Search...',
}) => {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
margin: '0 20px 14px',
}}
>
<IonSearchbar
value={value}
onIonInput={onChange}
placeholder={placeholder}
style={{
padding: 0,
'--border-radius': 'var(--radius-pill)',
'--background': 'rgba(255,255,255,0.06)',
'--color': 'var(--color-text-primary)',
'--placeholder-color': 'rgba(255,255,255,0.45)',
'--icon-color': 'rgba(255,255,255,0.75)',
'--clear-button-color': 'rgba(255,255,255,0.55)',
}}
/>
{onFilterClick && (
<IonButton
onClick={onFilterClick}
style={{
'--background': 'rgba(255,255,255,0.06)',
'--color': 'var(--color-text-primary)',
'--border-radius': '12px',
width: '44px',
height: '44px',
margin: 0,
flexShrink: 0,
'--padding-start': 0,
'--padding-end': 0,
'--box-shadow': 'none',
}}
>
<IonIcon
icon={optionsOutline}
slot="icon-only"
style={{ fontSize: '20px' }}
/>
</IonButton>
)}
</div>
);
};
export default SearchRow;
-63
View File
@@ -1,63 +0,0 @@
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;
+70 -14
View File
@@ -1,35 +1,69 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { User } from '@supabase/supabase-js';
import { User, Session } from '@supabase/supabase-js';
import { supabase } from '../supabase';
import { Database } from '../database.types';
import { IonSpinner } from '@ionic/react';
type Profile = Database['public']['Tables']['profiles']['Row'];
interface AuthContextType {
user: User | null;
session: Session | null;
profile: Profile | null;
loading: boolean;
refreshProfile: () => Promise<void>;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
user: null,
signOut: async () => {},
});
export const useAuth = () => useContext(AuthContext);
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [user, setUser] = useState<User | null>(null);
const [initializing, setInitializing] = useState(true);
const [session, setSession] = useState<Session | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true);
const refreshProfile = async (userId?: string) => {
const id = userId || user?.id;
if (!id) return;
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', id)
.single();
if (data) {
setProfile(data);
} else if (error && error.code !== 'PGRST116') {
console.error('Error fetching profile:', error);
}
};
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
setUser(data.session?.user ?? null);
setInitializing(false);
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
if (session?.user) {
refreshProfile(session.user.id).finally(() => setLoading(false));
} else {
setLoading(false);
}
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
setUser(session?.user ?? null);
if (session?.user) {
refreshProfile(session.user.id);
} else {
setProfile(null);
}
setLoading(false);
});
return () => subscription.unsubscribe();
@@ -39,13 +73,35 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
await supabase.auth.signOut();
};
if (initializing) {
return null; // Or a simple spinner if preferred, but usually handled nicely
if (loading) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
background: 'var(--color-bg)',
}}
>
<IonSpinner name="crescent" color="primary" />
</div>
);
}
return (
<AuthContext.Provider value={{ user, signOut }}>
<AuthContext.Provider
value={{ user, session, profile, loading, refreshProfile, signOut }}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
-81
View File
@@ -1,81 +0,0 @@
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>
);
};
+541 -136
View File
@@ -14,199 +14,604 @@ export type Database = {
}
public: {
Tables: {
profiles: {
check_ins: {
Row: {
avatar_url: string | null
city: string | null
company_name: string | null
created_at: string
full_name: string
checked_in_at: string | null
expires_at: string
id: string
phone: string | null
role: string
updated_at: string
place_id: string
user_id: string
}
Insert: {
avatar_url?: string | null
city?: string | null
company_name?: string | null
created_at?: string
full_name: string
id: string
phone?: string | null
role: string
updated_at?: string
checked_in_at?: string | null
expires_at: string
id?: string
place_id: string
user_id: string
}
Update: {
avatar_url?: string | null
city?: string | null
company_name?: string | null
created_at?: string
full_name?: string
checked_in_at?: string | null
expires_at?: string
id?: string
phone?: string | null
role?: string
updated_at?: string
place_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "check_ins_place_id_fkey"
columns: ["place_id"]
isOneToOne: false
referencedRelation: "places"
referencedColumns: ["id"]
},
{
foreignKeyName: "check_ins_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
conversations: {
Row: {
created_at: string | null
id: string
match_id: string
updated_at: string | null
}
Insert: {
created_at?: string | null
id?: string
match_id: string
updated_at?: string | null
}
Update: {
created_at?: string | null
id?: string
match_id?: string
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "conversations_match_id_fkey"
columns: ["match_id"]
isOneToOne: true
referencedRelation: "matches"
referencedColumns: ["id"]
},
]
}
discovery_preferences: {
Row: {
goals: string[]
max_age: number
max_distance_km: number
min_age: number
updated_at: string | null
user_id: string
}
Insert: {
goals?: string[]
max_age?: number
max_distance_km?: number
min_age?: number
updated_at?: string | null
user_id: string
}
Update: {
goals?: string[]
max_age?: number
max_distance_km?: number
min_age?: number
updated_at?: string | null
user_id?: string
}
Relationships: [
{
foreignKeyName: "discovery_preferences_user_id_fkey"
columns: ["user_id"]
isOneToOne: true
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
matches: {
Row: {
created_at: string | null
id: string
last_message_at: string | null
user_a: string
user_b: string
}
Insert: {
created_at?: string | null
id?: string
last_message_at?: string | null
user_a: string
user_b: string
}
Update: {
created_at?: string | null
id?: string
last_message_at?: string | null
user_a?: string
user_b?: string
}
Relationships: [
{
foreignKeyName: "matches_user_a_fkey"
columns: ["user_a"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "matches_user_b_fkey"
columns: ["user_b"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
messages: {
Row: {
body: string
conversation_id: string
created_at: string | null
id: string
read_at: string | null
sender_id: string
}
Insert: {
body: string
conversation_id: string
created_at?: string | null
id?: string
read_at?: string | null
sender_id: string
}
Update: {
body?: string
conversation_id?: string
created_at?: string | null
id?: string
read_at?: string | null
sender_id?: string
}
Relationships: [
{
foreignKeyName: "messages_conversation_id_fkey"
columns: ["conversation_id"]
isOneToOne: false
referencedRelation: "conversations"
referencedColumns: ["id"]
},
{
foreignKeyName: "messages_sender_id_fkey"
columns: ["sender_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
notifications: {
Row: {
body: string
created_at: string | null
deep_link: string | null
id: string
read_at: string | null
title: string
type: string
user_id: string
}
Insert: {
body: string
created_at?: string | null
deep_link?: string | null
id?: string
read_at?: string | null
title: string
type: string
user_id: string
}
Update: {
body?: string
created_at?: string | null
deep_link?: string | null
id?: string
read_at?: string | null
title?: string
type?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "notifications_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
payments: {
Row: {
amount: number
created_at: string | null
currency: string
gateway: string
id: string
metadata: Json
reference: string
status: string
updated_at: string | null
user_id: string
}
Insert: {
amount: number
created_at?: string | null
currency?: string
gateway?: string
id?: string
metadata?: Json
reference: string
status?: string
updated_at?: string | null
user_id: string
}
Update: {
amount?: number
created_at?: string | null
currency?: string
gateway?: string
id?: string
metadata?: Json
reference?: string
status?: string
updated_at?: string | null
user_id?: string
}
Relationships: []
}
quotations: {
places: {
Row: {
created_at: string
currency_code: string
delivery_option: string
address: string | null
busyness: string
category: string
created_at: string | null
created_by: string | null
description: string | null
google_place_id: string | null
id: string
note: string | null
price_amount: number
rfq_id: string
status: string
supplier_id: string
updated_at: string
image_path: string | null
latitude: number
longitude: number
name: string
rating: number
review_count: number
updated_at: string | null
}
Insert: {
created_at?: string
currency_code?: string
delivery_option: string
address?: string | null
busyness?: string
category?: string
created_at?: string | null
created_by?: string | null
description?: string | null
google_place_id?: string | null
id?: string
note?: string | null
price_amount: number
rfq_id: string
status?: string
supplier_id: string
updated_at?: string
image_path?: string | null
latitude: number
longitude: number
name: string
rating?: number
review_count?: number
updated_at?: string | null
}
Update: {
created_at?: string
currency_code?: string
delivery_option?: string
address?: string | null
busyness?: string
category?: string
created_at?: string | null
created_by?: string | null
description?: string | null
google_place_id?: string | null
id?: string
note?: string | null
price_amount?: number
rfq_id?: string
status?: string
supplier_id?: string
updated_at?: string
image_path?: string | null
latitude?: number
longitude?: number
name?: string
rating?: number
review_count?: number
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "quotations_rfq_id_fkey"
columns: ["rfq_id"]
isOneToOne: false
referencedRelation: "rfqs"
referencedColumns: ["id"]
},
{
foreignKeyName: "quotations_supplier_id_fkey"
columns: ["supplier_id"]
foreignKeyName: "places_created_by_fkey"
columns: ["created_by"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
rfq_images: {
profile_photos: {
Row: {
created_at: string
created_at: string | null
id: string
owner_id: string
rfq_id: string
is_primary: boolean
position: number
storage_path: string
user_id: string
}
Insert: {
created_at?: string
created_at?: string | null
id?: string
owner_id: string
rfq_id: string
is_primary?: boolean
position?: number
storage_path: string
user_id: string
}
Update: {
created_at?: string
created_at?: string | null
id?: string
owner_id?: string
rfq_id?: string
is_primary?: boolean
position?: number
storage_path?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "rfq_images_rfq_id_fkey"
columns: ["rfq_id"]
isOneToOne: false
referencedRelation: "rfqs"
referencedColumns: ["id"]
},
]
}
rfq_recipients: {
Row: {
created_at: string
id: string
rfq_id: string
supplier_id: string
}
Insert: {
created_at?: string
id?: string
rfq_id: string
supplier_id: string
}
Update: {
created_at?: string
id?: string
rfq_id?: string
supplier_id?: string
}
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"]
foreignKeyName: "profile_photos_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
rfqs: {
profiles: {
Row: {
buyer_id: string
created_at: string
avatar_path: string | null
bio: string | null
birth_date: string | null
city: string | null
created_at: string | null
fcm_token: string | null
full_name: string
gender: string | null
id: string
reference_number: string
request_text: string
status: string
title: string
updated_at: string
interests: string[]
latitude: number | null
longitude: number | null
onboarding_complete: boolean
relationship_goal: string | null
updated_at: string | null
}
Insert: {
buyer_id: string
created_at?: string
id?: string
reference_number: string
request_text: string
status?: string
title: string
updated_at?: string
avatar_path?: string | null
bio?: string | null
birth_date?: string | null
city?: string | null
created_at?: string | null
fcm_token?: string | null
full_name?: string
gender?: string | null
id: string
interests?: string[]
latitude?: number | null
longitude?: number | null
onboarding_complete?: boolean
relationship_goal?: string | null
updated_at?: string | null
}
Update: {
buyer_id?: string
created_at?: string
avatar_path?: string | null
bio?: string | null
birth_date?: string | null
city?: string | null
created_at?: string | null
fcm_token?: string | null
full_name?: string
gender?: string | null
id?: string
reference_number?: string
request_text?: string
status?: string
title?: string
updated_at?: string
interests?: string[]
latitude?: number | null
longitude?: number | null
onboarding_complete?: boolean
relationship_goal?: string | null
updated_at?: string | null
}
Relationships: []
}
reel_comments: {
Row: {
body: string
created_at: string | null
id: string
reel_id: string
user_id: string
}
Insert: {
body: string
created_at?: string | null
id?: string
reel_id: string
user_id: string
}
Update: {
body?: string
created_at?: string | null
id?: string
reel_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "rfqs_buyer_id_fkey"
columns: ["buyer_id"]
foreignKeyName: "reel_comments_reel_id_fkey"
columns: ["reel_id"]
isOneToOne: false
referencedRelation: "reels"
referencedColumns: ["id"]
},
{
foreignKeyName: "reel_comments_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
reel_likes: {
Row: {
created_at: string | null
reel_id: string
user_id: string
}
Insert: {
created_at?: string | null
reel_id: string
user_id: string
}
Update: {
created_at?: string | null
reel_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "reel_likes_reel_id_fkey"
columns: ["reel_id"]
isOneToOne: false
referencedRelation: "reels"
referencedColumns: ["id"]
},
{
foreignKeyName: "reel_likes_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
reels: {
Row: {
author_id: string
caption: string
comments_count: number
created_at: string | null
id: string
likes_count: number
media_path: string
media_type: string
place_id: string
shares_count: number
thumbnail_path: string | null
}
Insert: {
author_id: string
caption?: string
comments_count?: number
created_at?: string | null
id?: string
likes_count?: number
media_path: string
media_type: string
place_id: string
shares_count?: number
thumbnail_path?: string | null
}
Update: {
author_id?: string
caption?: string
comments_count?: number
created_at?: string | null
id?: string
likes_count?: number
media_path?: string
media_type?: string
place_id?: string
shares_count?: number
thumbnail_path?: string | null
}
Relationships: [
{
foreignKeyName: "reels_author_id_fkey"
columns: ["author_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "reels_place_id_fkey"
columns: ["place_id"]
isOneToOne: false
referencedRelation: "places"
referencedColumns: ["id"]
},
]
}
subscriptions: {
Row: {
active_until: string | null
tier: string
updated_at: string | null
user_id: string
}
Insert: {
active_until?: string | null
tier?: string
updated_at?: string | null
user_id: string
}
Update: {
active_until?: string | null
tier?: string
updated_at?: string | null
user_id?: string
}
Relationships: []
}
swipes: {
Row: {
action: string
created_at: string | null
id: string
swiped_id: string
swiper_id: string
}
Insert: {
action: string
created_at?: string | null
id?: string
swiped_id: string
swiper_id: string
}
Update: {
action?: string
created_at?: string | null
id?: string
swiped_id?: string
swiper_id?: string
}
Relationships: [
{
foreignKeyName: "swipes_swiped_id_fkey"
columns: ["swiped_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "swipes_swiper_id_fkey"
columns: ["swiper_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
-86
View File
@@ -1,86 +0,0 @@
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 };
};
-576
View File
@@ -1,576 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import {
IonPage,
IonContent,
IonSpinner,
IonIcon,
useIonViewWillEnter,
} from '@ionic/react';
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 { 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 [error, setError] = useState('');
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(''), 4000);
};
const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
const { error } = await supabase.auth.signInWithPassword({
email: loginEmail,
password: loginPassword,
});
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);
}
setLoading(false);
return;
}
// keep loading true waiting for user effect
};
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault();
if (registerPassword !== confirmPassword) {
showError('Passwords do not match');
return;
}
setLoading(true);
setError('');
const { error } = await supabase.auth.signUp({
email: registerEmail,
password: registerPassword,
});
if (error) {
showError(error.message);
setLoading(false);
return;
}
setLoading(false);
history.push('/verify-email', { email: registerEmail });
};
const handleForgotPassword = () => {
history.push('/forgot-password');
};
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': '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>
<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',
}}
>
{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>
<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 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',
}}
>
Forgot password?
</button>
</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' }}
/>
) : (
'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>
);
};
export default Auth;
+425
View File
@@ -0,0 +1,425 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonInput,
IonItem,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonRouterLink,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { mail, lockClosed, logoFacebook } from 'ionicons/icons';
import { IonIcon } from '@ionic/react';
import { enablePreviewMode } from '../utils/previewMode';
import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { Capacitor } from '@capacitor/core';
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
const AuthPage: React.FC = () => {
const history = useHistory();
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleAuth = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
if (mode === 'signin') {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
if (error.message.toLowerCase().includes('email not confirmed')) {
await supabase.auth.resend({ type: 'signup', email });
history.push('/verify-email', { email, type: 'signup' });
return;
}
throw error;
}
history.push('/icebreaker');
} else {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: { onboarding_complete: false },
},
});
if (error) throw error;
if (data.session) {
history.push('/onboarding/profile');
} else {
history.push('/verify-email', { email, type: 'signup' });
}
}
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
const enterPreviewMode = () => {
enablePreviewMode();
history.push('/icebreaker');
};
const handleSocialSignIn = async (provider: 'google' | 'apple') => {
if (!Capacitor.isNativePlatform()) {
showError(
`${provider === 'google' ? 'Google' : 'Apple'} Sign-In is only available on native devices.`
);
return;
}
setLoading(true);
try {
let result;
if (provider === 'google') {
result = await FirebaseAuthentication.signInWithGoogle();
} else {
result = await FirebaseAuthentication.signInWithApple({
skipNativeAuth: true,
});
}
const { error } = await supabase.auth.signInWithIdToken({
provider,
token: result.credential!.idToken!,
nonce: result.credential?.nonce,
});
if (error) throw error;
history.push('/icebreaker');
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
className="ion-padding"
style={{
'--background': 'var(--color-bg)',
display: 'flex',
flexDirection: 'column',
paddingTop: 'calc(var(--ion-safe-area-top) + 40px)',
}}
>
<div style={{ padding: '0 20px' }}>
<div style={{ marginBottom: '44px', paddingTop: '24px' }}>
<h1
style={{
fontSize: '28px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 42px',
}}
>
{mode === 'signin' ? 'Hi there, 👋' : 'Create account'}
</h1>
<h2
style={{
fontSize: '24px',
fontWeight: 700,
color: 'var(--color-text-primary)',
margin: '0 0 12px',
}}
>
{mode === 'signin'
? 'Sign in to continue'
: 'Sign up to continue'}
</h2>
<p
style={{
fontSize: '16px',
color: 'var(--color-text-secondary)',
margin: 0,
lineHeight: 1.35,
}}
>
{mode === 'signin'
? "Don't have an account? Enter your email and password or choose one of the options below."
: 'Enter your email and password to start discovering people and places nearby.'}
</p>
</div>
<div
style={{
display: 'flex',
background: 'var(--color-surface-raised)',
padding: '4px',
borderRadius: 'var(--radius-pill)',
marginBottom: '20px',
}}
>
{(['signin', 'signup'] as const).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
style={{
flex: 1,
padding: '10px',
borderRadius: 'var(--radius-pill)',
border: 'none',
fontSize: '14px',
fontWeight: 700,
transition: '0.2s',
background:
mode === m ? 'var(--ion-color-primary)' : 'transparent',
color: mode === m ? '#ffffff' : 'var(--color-text-primary)',
}}
>
{m === 'signin' ? 'Sign In' : 'Sign Up'}
</button>
))}
</div>
<form
onSubmit={handleAuth}
style={{
background: 'transparent',
padding: '0',
borderRadius: '0',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-input)',
'--border-radius': 'var(--radius-control)',
'--border-width': '1px',
'--border-style': 'solid',
'--border-color': 'var(--color-text-secondary)',
'--min-height': '48px',
}}
>
<IonIcon
icon={mail}
slot="start"
style={{ color: 'var(--ion-color-primary)', fontSize: '20px' }}
/>
<IonInput
label="Email"
labelPlacement="floating"
type="email"
value={email}
onIonInput={(e) => setEmail(e.detail.value!)}
required
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-input)',
'--border-radius': 'var(--radius-control)',
'--border-width': '1px',
'--border-style': 'solid',
'--border-color': 'var(--color-text-secondary)',
'--min-height': '48px',
}}
>
<IonIcon
icon={lockClosed}
slot="start"
style={{ color: 'var(--ion-color-primary)', fontSize: '20px' }}
/>
<IonInput
label="Password"
labelPlacement="floating"
type="password"
value={password}
onIonInput={(e) => setPassword(e.detail.value!)}
required
/>
</IonItem>
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
}}
>
{error}
</div>
)}
<IonButton
type="submit"
expand="block"
disabled={loading}
style={{
'--border-radius': 'var(--radius-pill)',
'--color': '#000000',
fontWeight: 800,
height: '48px',
margin: '8px 28px 0',
}}
>
{loading ? (
<IonSpinner name="crescent" />
) : mode === 'signin' ? (
'Sign in'
) : (
'Continue'
)}
</IonButton>
{mode === 'signin' && (
<IonRouterLink
routerLink="/forgot-password"
style={{
textAlign: 'center',
fontSize: '13px',
color: 'var(--ion-color-primary)',
marginTop: '8px',
}}
>
Forgot password?
</IonRouterLink>
)}
</form>
<div
style={{
textAlign: 'center',
margin: '56px 0 26px',
fontSize: '13px',
color: 'var(--color-text-tertiary)',
}}
>
OR
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
padding: '0 28px',
}}
>
<IonButton
expand="block"
onClick={() => handleSocialSignIn('apple')}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
style={{ marginRight: '10px' }}
>
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
Sign in with Apple
</IonButton>
<IonButton
expand="block"
onClick={() =>
showError(
'Facebook Sign-In is only available on native devices.'
)
}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<IonIcon
icon={logoFacebook}
style={{ marginRight: '10px', fontSize: '22px' }}
/>
Sign in with Facebook
</IonButton>
<IonButton
expand="block"
onClick={() => handleSocialSignIn('google')}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<span
style={{
fontSize: '24px',
fontWeight: 900,
marginRight: '12px',
}}
>
G
</span>
Sign in with Google
</IonButton>
<IonButton
expand="block"
onClick={enterPreviewMode}
style={{
flex: 1,
'--background': 'var(--ion-color-primary)',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
Preview with dummy data
</IonButton>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default AuthPage;
-324
View File
@@ -1,324 +0,0 @@
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;
-343
View File
@@ -1,343 +0,0 @@
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;
+311
View File
@@ -0,0 +1,311 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonIcon,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonItem,
IonTextarea,
IonSelect,
IonSelectOption,
} from '@ionic/react';
import {
closeOutline,
cameraOutline,
imageOutline,
sendOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const CameraPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [caption, setCaption] = useState('');
const [placeId, setPlaceId] = useState<string | null>(null);
const [places, setPlaces] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [isPublishOpen, setIsPublishOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchPlaces();
});
const fetchPlaces = async () => {
const { data } = await supabase.from('places').select('id, name');
setPlaces(data || []);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewUrl(URL.createObjectURL(selectedFile));
setIsPublishOpen(true);
}
};
const handlePublish = async () => {
if (!file || !user || !placeId) {
setError('Please select a place and add a file.');
return;
}
setUploading(true);
try {
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
const filePath = `${fileName}`;
// 1. Upload to storage
const { error: uploadError } = await supabase.storage
.from('reels')
.upload(filePath, file);
if (uploadError) throw uploadError;
// 2. Create reel record
const { error: reelError } = await supabase.from('reels').insert({
author_id: user.id,
place_id: placeId,
caption,
media_path: filePath,
media_type: file.type.startsWith('video') ? 'video' : 'image',
});
if (reelError) throw reelError;
history.replace(`/reels/${placeId}`);
} catch (err: any) {
setError(err.message);
} finally {
setUploading(false);
}
};
return (
<IonPage>
<IonContent fullscreen style={{ '--background': '#000000' }}>
<div
style={{
position: 'absolute',
top: 'calc(14px + var(--ion-safe-area-top))',
left: '20px',
zIndex: 10,
}}
>
<IonButton
fill="clear"
onClick={() => history.goBack()}
style={{
'--background': 'rgba(0,0,0,0.3)',
'--color': '#ffffff',
'--border-radius': '50%',
width: '40px',
height: '40px',
margin: 0,
}}
>
<IonIcon icon={closeOutline} slot="icon-only" />
</IonButton>
</div>
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
}}
>
<div
style={{
width: '120px',
height: '120px',
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '24px',
}}
>
<IonIcon
icon={cameraOutline}
style={{ fontSize: '48px', opacity: 0.5 }}
/>
</div>
<h2
style={{ fontSize: '20px', fontWeight: 800, marginBottom: '8px' }}
>
Create a Reel
</h2>
<p
style={{
opacity: 0.6,
fontSize: '14px',
marginBottom: '32px',
textAlign: 'center',
padding: '0 40px',
}}
>
Share your experience at a local spot. Select a photo or video to
begin.
</p>
<label
style={{
background: 'var(--ion-color-primary)',
color: '#ffffff',
padding: '14px 32px',
borderRadius: 'var(--radius-pill)',
fontWeight: 700,
fontSize: '15px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<IonIcon icon={imageOutline} style={{ fontSize: '20px' }} />
Select Media
<input
type="file"
accept="image/*,video/*"
onChange={handleFileChange}
hidden
/>
</label>
</div>
{/* Publish Modal */}
<IonModal
isOpen={isPublishOpen}
onDidDismiss={() => {
setIsPublishOpen(false);
setPreviewUrl(null);
setFile(null);
}}
>
<IonHeader>
<IonToolbar>
<IonTitle>New Reel</IonTitle>
<IonButtons slot="start">
<IonButton onClick={() => setIsPublishOpen(false)}>
Cancel
</IonButton>
</IonButtons>
<IonButtons slot="end">
<IonButton
onClick={handlePublish}
disabled={uploading || !placeId}
>
{uploading ? <IonSpinner name="crescent" /> : 'Share'}
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
<div
style={{
width: '100px',
height: '140px',
borderRadius: '12px',
overflow: 'hidden',
background: '#000',
flexShrink: 0,
}}
>
{file?.type.startsWith('video') ? (
<video
src={previewUrl || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<img
src={previewUrl || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
alt="Preview"
/>
)}
</div>
<IonTextarea
placeholder="Write a caption..."
value={caption}
onIonInput={(e) => setCaption(e.detail.value!)}
rows={6}
style={{
'--background': 'var(--color-bg)',
'--padding-start': '12px',
'--border-radius': '12px',
}}
/>
</div>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Tag a place"
labelPlacement="floating"
placeholder="Select place"
value={placeId}
onIonChange={(e) => setPlaceId(e.detail.value)}
>
{places.map((p) => (
<IonSelectOption key={p.id} value={p.id}>
{p.name}
</IonSelectOption>
))}
</IonSelect>
</IonItem>
{error && (
<div
style={{
color: 'var(--ion-color-danger)',
fontSize: '13px',
marginTop: '12px',
padding: '0 4px',
}}
>
{error}
</div>
)}
</IonContent>
</IonModal>
</IonContent>
</IonPage>
);
};
export default CameraPage;
+263
View File
@@ -0,0 +1,263 @@
import React, { useState, useEffect, useRef } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonInput,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { chevronBackOutline, sendOutline } from 'ionicons/icons';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import Avatar from '../components/Avatar';
import { previewChats, previewMessages } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const ChatThreadPage: React.FC = () => {
const history = useHistory();
const { conversationId } = useParams<{ conversationId: string }>();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [messages, setMessages] = useState<any[]>([]);
const [otherUser, setOtherUser] = useState<any>(null);
const [text, setText] = useState('');
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLIonContentElement>(null);
const fetchThread = async () => {
setLoading(true);
if (previewMode) {
const chat =
previewChats.find((item) => item.id === conversationId) ||
previewChats[0];
setOtherUser(chat.otherUser);
setMessages(previewMessages[chat.id] || []);
setLoading(false);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
return;
}
if (!user) return;
try {
// 1. Get other participant
const { data: conv } = await supabase
.from('conversations')
.select(
`
match_id,
matches(
user_a:profiles!matches_user_a_fkey(id, full_name, avatar_path),
user_b:profiles!matches_user_b_fkey(id, full_name, avatar_path)
)
`
)
.eq('id', conversationId)
.single();
if (conv) {
const match = (conv as any).matches;
setOtherUser(match.user_a.id === user.id ? match.user_b : match.user_a);
}
// 2. Get messages
const { data: msgs } = await supabase
.from('messages')
.select('*')
.eq('conversation_id', conversationId)
.order('created_at', { ascending: true });
setMessages(msgs || []);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
} catch (err: any) {
console.error('Fetch thread error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchThread();
});
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim() || (!user && !previewMode) || sending) return;
setSending(true);
const msgBody = text.trim();
setText('');
if (previewMode) {
const previewMessage = {
id: `preview-message-${Date.now()}`,
sender_id: 'preview-me',
body: msgBody,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, previewMessage]);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
setSending(false);
return;
}
try {
const { data, error } = await supabase
.from('messages')
.insert({
conversation_id: conversationId,
sender_id: user.id,
body: msgBody,
})
.select()
.single();
if (error) throw error;
setMessages((prev) => [...prev, data]);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
// Invoke notification
supabase.functions.invoke('send-message-notification', {
body: { conversationId, messageId: data.id },
});
} catch (err: any) {
console.error('Send error:', err);
} finally {
setSending(false);
}
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
<IonButtons slot="start">
<IonButton
onClick={() => history.goBack()}
style={{ '--color': 'var(--color-text-primary)' }}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle>
{otherUser && (
<div
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
<Avatar
src={otherUser.avatar_path}
name={otherUser.full_name}
size={32}
/>
<span style={{ fontSize: '16px', fontWeight: 700 }}>
{otherUser.full_name}
</span>
</div>
)}
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent
ref={scrollRef}
style={{ '--background': 'var(--color-bg)' }}
className="ion-padding"
>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
paddingBottom: '20px',
}}
>
{messages.map((msg) => {
const isMine = previewMode
? msg.sender_id === 'preview-me'
: msg.sender_id === user?.id;
return (
<div
key={msg.id}
style={{
alignSelf: isMine ? 'flex-end' : 'flex-start',
maxWidth: '78%',
background: isMine
? 'var(--ion-color-primary)'
: 'var(--color-surface)',
color: isMine ? '#ffffff' : 'var(--color-text-primary)',
padding: '10px 14px',
borderRadius: isMine
? '18px 18px 4px 18px'
: '18px 18px 18px 4px',
fontSize: '14px',
lineHeight: 1.4,
boxShadow: '0 2px 8px rgba(76, 5, 25, 0.02)',
}}
>
{msg.body}
</div>
);
})}
</div>
)}
</IonContent>
<div
style={{
background: '#ffffff',
padding: '10px 20px calc(10px + var(--ion-safe-area-bottom))',
borderTop: '1px solid var(--color-border)',
}}
>
<form
onSubmit={handleSend}
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
>
<IonInput
value={text}
onIonInput={(e) => setText(e.detail.value!)}
placeholder="Type a message..."
style={{
background: 'var(--color-bg)',
'--border-radius': 'var(--radius-pill)',
'--padding-start': '16px',
fontSize: '14px',
}}
/>
<IonButton
type="submit"
disabled={!text.trim() || sending}
style={{
'--border-radius': '50%',
width: '44px',
height: '44px',
margin: 0,
flexShrink: 0,
}}
>
{sending ? (
<IonSpinner name="crescent" />
) : (
<IonIcon icon={sendOutline} slot="icon-only" />
)}
</IonButton>
</form>
</div>
</IonPage>
);
};
export default ChatThreadPage;
+218
View File
@@ -0,0 +1,218 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonSpinner,
useIonViewWillEnter,
IonRefresher,
IonRefresherContent,
IonIcon,
} from '@ionic/react';
import { chatbubbleEllipsesOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { formatTime } from '../utils/format';
import SearchRow from '../components/SearchRow';
import Avatar from '../components/Avatar';
import EmptyState from '../components/EmptyState';
import { previewChats } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const ChatsPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [chats, setChats] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const fetchChats = async () => {
if (previewMode) {
setChats(previewChats);
setLoading(false);
return;
}
if (!user) return;
setLoading(true);
try {
const { data, error } = await supabase
.from('matches')
.select(
`
id,
last_message_at,
user_a:profiles!matches_user_a_fkey(id, full_name, avatar_path),
user_b:profiles!matches_user_b_fkey(id, full_name, avatar_path),
conversations(
id,
messages(
body,
created_at
)
)
`
)
.or(`user_a.eq.${user.id},user_b.eq.${user.id}`)
.order('last_message_at', { ascending: false });
if (error) throw error;
const formatted =
data?.map((m) => {
const otherUser = m.user_a.id === user.id ? m.user_b : m.user_a;
const conversation = m.conversations?.[0];
const lastMsg = conversation?.messages?.[0]; // ordered by handle-swipe or trigger later
return {
id: conversation?.id,
otherUser,
lastMessage: lastMsg?.body || 'New match! Start chatting.',
timestamp: m.last_message_at || lastMsg?.created_at,
};
}) || [];
setChats(formatted);
} catch (err: any) {
console.error('Fetch chats error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchChats();
});
const handleRefresh = async (event: CustomEvent) => {
await fetchChats();
event.detail.complete();
};
const displayChats = previewMode ? previewChats : chats;
const filteredChats = displayChats.filter(
(c) =>
c.otherUser.full_name.toLowerCase().includes(query.toLowerCase()) ||
c.lastMessage.toLowerCase().includes(query.toLowerCase())
);
return (
<IonPage>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div
style={{ padding: 'calc(16px + var(--ion-safe-area-top)) 20px 12px' }}
>
<h1
style={{
fontSize: '24px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: 0,
}}
>
Chats
</h1>
</div>
<SearchRow
value={query}
onChange={(e) => setQuery(e.detail.value!)}
placeholder="Search conversations"
/>
<div
style={{ paddingBottom: 'calc(92px + var(--ion-safe-area-bottom))' }}
>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : filteredChats.length === 0 ? (
<EmptyState
icon={chatbubbleEllipsesOutline}
title="No chats yet"
message="Matches you message will appear here. Start swiping to find someone!"
ctaText="Find people"
onCtaClick={() => history.push('/icebreaker')}
/>
) : (
filteredChats.map((chat) => (
<div
key={chat.id}
onClick={() => history.push(`/chat/${chat.id}`)}
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
margin: '0 20px 12px',
padding: '16px',
display: 'flex',
alignItems: 'center',
gap: '12px',
boxShadow: 'var(--shadow-card)',
}}
>
<Avatar
src={chat.otherUser.image || chat.otherUser.avatar_path}
name={chat.otherUser.full_name}
size={56}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '4px',
}}
>
<h3
style={{
fontSize: '16px',
fontWeight: 800,
margin: 0,
color: 'var(--color-text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{chat.otherUser.full_name}
</h3>
<span
style={{
fontSize: '11px',
color: 'var(--color-text-tertiary)',
fontWeight: 600,
}}
>
{formatTime(chat.timestamp)}
</span>
</div>
<p
style={{
fontSize: '14px',
color: 'var(--color-text-secondary)',
margin: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{chat.lastMessage}
</p>
</div>
</div>
))
)}
</div>
</IonContent>
</IonPage>
);
};
export default ChatsPage;

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