Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c279e86750 |
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -4,7 +4,7 @@ android {
|
|||||||
namespace = "io.ionic.starter"
|
namespace = "io.ionic.starter"
|
||||||
compileSdk = rootProject.ext.compileSdkVersion
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "io.shopkeep.app"
|
applicationId "com.habitmode.llemba.com"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 1
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -21,12 +22,6 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data android:scheme="io.shopkeep.app" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">ShopKeep</string>
|
<string name="app_name">app</string>
|
||||||
<string name="title_activity_main">ShopKeep</string>
|
<string name="title_activity_main">app</string>
|
||||||
<string name="package_name">io.ionic.starter</string>
|
<string name="package_name">io.ionic.starter</string>
|
||||||
<string name="custom_url_scheme">io.ionic.starter</string>
|
<string name="custom_url_scheme">io.ionic.starter</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ ext {
|
|||||||
cordovaAndroidVersion = '14.0.1'
|
cordovaAndroidVersion = '14.0.1'
|
||||||
rgcfaIncludeGoogle = true
|
rgcfaIncludeGoogle = true
|
||||||
androidxCredentialsVersion = '1.3.0'
|
androidxCredentialsVersion = '1.3.0'
|
||||||
|
javaVersion = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
+3
-3
@@ -1,13 +1,13 @@
|
|||||||
import type { CapacitorConfig } from '@capacitor/cli';
|
import type { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
const config: CapacitorConfig = {
|
const config: CapacitorConfig = {
|
||||||
appId: 'io.shopkeep.app',
|
appId: 'com.habitmode.llemba.com',
|
||||||
appName: 'ShopKeep',
|
appName: 'HabitMode',
|
||||||
webDir: 'dist',
|
webDir: 'dist',
|
||||||
plugins: {
|
plugins: {
|
||||||
FirebaseAuthentication: {
|
FirebaseAuthentication: {
|
||||||
skipNativeAuth: false,
|
skipNativeAuth: false,
|
||||||
providers: [],
|
providers: ["google.com", "apple.com"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ workflows:
|
|||||||
if [ -n "$CM_DEBUG_KEYSTORE" ]; then
|
if [ -n "$CM_DEBUG_KEYSTORE" ]; then
|
||||||
echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12
|
echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12
|
||||||
fi
|
fi
|
||||||
|
- name: Check native credentials
|
||||||
|
script: |
|
||||||
|
if [ -n "$VITE_GOOGLE_MAPS_KEY" ]; then
|
||||||
|
echo "VITE_GOOGLE_MAPS_KEY: set (length=${#VITE_GOOGLE_MAPS_KEY}, ends …${VITE_GOOGLE_MAPS_KEY: -4})"
|
||||||
|
else
|
||||||
|
echo "WARNING: VITE_GOOGLE_MAPS_KEY not set — Google Maps will not render"
|
||||||
|
fi
|
||||||
- name: Build debug APK
|
- name: Build debug APK
|
||||||
script: |
|
script: |
|
||||||
cd android
|
cd android
|
||||||
@@ -68,6 +75,21 @@ workflows:
|
|||||||
else
|
else
|
||||||
./gradlew assembleDebug --no-daemon
|
./gradlew assembleDebug --no-daemon
|
||||||
fi
|
fi
|
||||||
|
- name: Verify manifest substitution
|
||||||
|
script: |
|
||||||
|
MANIFEST="android/app/build/intermediates/merged_manifests/debug/AndroidManifest.xml"
|
||||||
|
if [ -f "$MANIFEST" ]; then
|
||||||
|
VAL=$(grep -A1 'geo.API_KEY' "$MANIFEST" | grep -o 'android:value="[^"]*"' | cut -d'"' -f2)
|
||||||
|
if [ -z "$VAL" ]; then
|
||||||
|
echo "WARNING: geo.API_KEY meta-data not found in merged manifest"
|
||||||
|
elif [ "$VAL" = '${googleMapsApiKey}' ]; then
|
||||||
|
echo "WARNING: geo.API_KEY placeholder was not substituted — key missing from build env"
|
||||||
|
else
|
||||||
|
echo "geo.API_KEY substituted OK (ends …${VAL: -4})"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Merged manifest not found at $MANIFEST"
|
||||||
|
fi
|
||||||
artifacts:
|
artifacts:
|
||||||
- android/app/build/outputs/apk/debug/app-debug.apk
|
- android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -308,7 +308,7 @@
|
|||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.shopkeep.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -330,7 +330,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.shopkeep.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.developer.applesignin</key>
|
||||||
|
<array>
|
||||||
|
<string>Default</string>
|
||||||
|
</array>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>production</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Capacitor
|
import Capacitor
|
||||||
|
import FirebaseCore
|
||||||
|
import FirebaseMessaging
|
||||||
|
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
@@ -7,43 +9,40 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
// Override point for customization after application launch.
|
FirebaseApp.configure()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillResignActive(_ application: UIApplication) {
|
func applicationWillResignActive(_ application: UIApplication) {}
|
||||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
|
||||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
func applicationDidEnterBackground(_ application: UIApplication) {}
|
||||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
|
||||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
func applicationWillEnterForeground(_ application: UIApplication) {}
|
||||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
func applicationDidBecomeActive(_ application: UIApplication) {}
|
||||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
func applicationWillTerminate(_ application: UIApplication) {}
|
||||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
|
||||||
}
|
|
||||||
|
|
||||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||||
// Called when the app was launched with a url. Feel free to add additional processing here,
|
|
||||||
// but if you want the App API to support tracking app url opens, make sure to keep this call
|
|
||||||
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||||
// Called when the app was launched with an activity, including Universal Links.
|
|
||||||
// Feel free to add additional processing here, but if you want the App API to support
|
|
||||||
// tracking app url opens, make sure to keep this call
|
|
||||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||||
|
Messaging.messaging().apnsToken = deviceToken
|
||||||
|
Messaging.messaging().token { token, error in
|
||||||
|
if let error = error {
|
||||||
|
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
|
||||||
|
} else if let token = token {
|
||||||
|
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||||
|
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>en</string>
|
<string>en</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>ShopKeep</string>
|
<string>HabitMode</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -50,11 +50,9 @@
|
|||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLName</key>
|
|
||||||
<string>io.shopkeep.app</string>
|
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>io.shopkeep.app</string>
|
<string>com.googleusercontent.apps.1097913965629-pf48952i0uv9shgvie3dq11s18sd0t2p</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
|||||||
Generated
+3404
File diff suppressed because it is too large
Load Diff
+3
-4
@@ -20,10 +20,9 @@
|
|||||||
"@capacitor/keyboard": "8.0.3",
|
"@capacitor/keyboard": "8.0.3",
|
||||||
"@capacitor/push-notifications": "^8.1.1",
|
"@capacitor/push-notifications": "^8.1.1",
|
||||||
"@capacitor/status-bar": "8.0.2",
|
"@capacitor/status-bar": "8.0.2",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@ionic/pwa-elements": "^3.4.0",
|
||||||
"@ionic/pwa-elements": "^3.0.0",
|
"@ionic/react": "^8.5.0",
|
||||||
"@ionic/react": "8.8.7",
|
"@ionic/react-router": "^8.5.0",
|
||||||
"@ionic/react-router": "8.8.7",
|
|
||||||
"@supabase/supabase-js": "^2.0.0",
|
"@supabase/supabase-js": "^2.0.0",
|
||||||
"firebase": "^11.0.0",
|
"firebase": "^11.0.0",
|
||||||
"ionicons": "^7.4.0",
|
"ionicons": "^7.4.0",
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
+14
-129
@@ -1,50 +1,12 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { Redirect, Route } from 'react-router-dom';
|
import { Redirect, Route } from 'react-router-dom';
|
||||||
import {
|
import { IonApp, IonRouterOutlet } from '@ionic/react';
|
||||||
IonApp,
|
|
||||||
IonRouterOutlet,
|
|
||||||
IonTabs,
|
|
||||||
IonTabBar,
|
|
||||||
IonTabButton,
|
|
||||||
IonIcon,
|
|
||||||
IonLabel,
|
|
||||||
setupIonicReact,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import { IonReactRouter } from '@ionic/react-router';
|
import { IonReactRouter } from '@ionic/react-router';
|
||||||
import {
|
|
||||||
homeOutline,
|
|
||||||
peopleOutline,
|
|
||||||
receiptOutline,
|
|
||||||
home,
|
|
||||||
people,
|
|
||||||
receipt,
|
|
||||||
} from 'ionicons/icons';
|
|
||||||
|
|
||||||
import Home from './pages/Home';
|
|
||||||
import CustomersPage from './pages/CustomersPage';
|
|
||||||
import CustomerDetailPage from './pages/CustomerDetailPage';
|
|
||||||
import HistoryPage from './pages/HistoryPage';
|
|
||||||
import SaleFormPage from './pages/SaleFormPage';
|
|
||||||
import CreditFormPage from './pages/CreditFormPage';
|
|
||||||
import RepaymentFormPage from './pages/RepaymentFormPage';
|
|
||||||
import AuthPage from './pages/AuthPage';
|
|
||||||
import VerifyEmailPage from './pages/VerifyEmailPage';
|
|
||||||
import ForgotPasswordPage from './pages/ForgotPasswordPage';
|
|
||||||
import NewPasswordPage from './pages/NewPasswordPage';
|
|
||||||
|
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
|
||||||
import { setStatusBarStyle, Style } from './utils/statusBar';
|
|
||||||
|
|
||||||
/* Core CSS required for Ionic components to work properly */
|
|
||||||
import '@ionic/react/css/core.css';
|
import '@ionic/react/css/core.css';
|
||||||
|
|
||||||
/* Basic CSS for apps built with Ionic */
|
|
||||||
import '@ionic/react/css/normalize.css';
|
import '@ionic/react/css/normalize.css';
|
||||||
import '@ionic/react/css/structure.css';
|
import '@ionic/react/css/structure.css';
|
||||||
import '@ionic/react/css/typography.css';
|
import '@ionic/react/css/typography.css';
|
||||||
|
|
||||||
/* Optional CSS utils that can be commented out */
|
|
||||||
import '@ionic/react/css/padding.css';
|
import '@ionic/react/css/padding.css';
|
||||||
import '@ionic/react/css/float-elements.css';
|
import '@ionic/react/css/float-elements.css';
|
||||||
import '@ionic/react/css/text-alignment.css';
|
import '@ionic/react/css/text-alignment.css';
|
||||||
@@ -52,104 +14,27 @@ import '@ionic/react/css/text-transformation.css';
|
|||||||
import '@ionic/react/css/flex-utils.css';
|
import '@ionic/react/css/flex-utils.css';
|
||||||
import '@ionic/react/css/display.css';
|
import '@ionic/react/css/display.css';
|
||||||
|
|
||||||
/**
|
|
||||||
* Ionic Dark Mode
|
|
||||||
* -----------------------------------------------------
|
|
||||||
* For more info, please see:
|
|
||||||
* https://ionicframework.com/docs/theming/dark-mode
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* import '@ionic/react/css/palettes/dark.always.css'; */
|
|
||||||
/* import '@ionic/react/css/palettes/dark.class.css'; */
|
|
||||||
import '@ionic/react/css/palettes/dark.system.css';
|
|
||||||
|
|
||||||
/* Theme variables */
|
|
||||||
import './theme/variables.css';
|
import './theme/variables.css';
|
||||||
|
|
||||||
setupIonicReact();
|
import Home from './pages/Home';
|
||||||
|
import Account from './pages/Account';
|
||||||
|
import Auth from './pages/Auth';
|
||||||
|
import Stats from './pages/Stats';
|
||||||
|
import HabitDetail from './pages/HabitDetail';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App = (): any => (
|
||||||
useEffect(() => {
|
|
||||||
// Set status bar to light icons on our dark-ish backgrounds
|
|
||||||
setStatusBarStyle(Style.Dark);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonApp>
|
<IonApp>
|
||||||
<AuthProvider>
|
|
||||||
<IonReactRouter>
|
<IonReactRouter>
|
||||||
<IonRouterOutlet>
|
<IonRouterOutlet>
|
||||||
{/* Auth Routes */}
|
<Route exact path="/auth" component={Auth} />
|
||||||
<Route path="/auth" component={AuthPage} exact />
|
<Route exact path="/home" component={Home} />
|
||||||
<Route path="/verify-email" component={VerifyEmailPage} exact />
|
<Route exact path="/stats" component={Stats} />
|
||||||
<Route
|
<Route exact path="/habit/:id" component={HabitDetail} />
|
||||||
path="/forgot-password"
|
<Route exact path="/account" component={Account} />
|
||||||
component={ForgotPasswordPage}
|
|
||||||
exact
|
|
||||||
/>
|
|
||||||
<Route path="/new-password" component={NewPasswordPage} exact />
|
|
||||||
|
|
||||||
{/* Protected Form & Detail Routes (Outer Outlet for push/pop animation) */}
|
|
||||||
<ProtectedRoute path="/sale/new" component={SaleFormPage} exact />
|
|
||||||
<ProtectedRoute
|
|
||||||
path="/credit/new"
|
|
||||||
component={CreditFormPage}
|
|
||||||
exact
|
|
||||||
/>
|
|
||||||
<ProtectedRoute
|
|
||||||
path="/repayment/new"
|
|
||||||
component={RepaymentFormPage}
|
|
||||||
exact
|
|
||||||
/>
|
|
||||||
<ProtectedRoute
|
|
||||||
path="/customers/:id([0-9a-fA-F-]{36})"
|
|
||||||
component={CustomerDetailPage}
|
|
||||||
exact
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Tab Shell */}
|
|
||||||
<Route
|
|
||||||
path={['/home', '/customers', '/history']}
|
|
||||||
render={() => (
|
|
||||||
<IonTabs>
|
|
||||||
<IonRouterOutlet>
|
|
||||||
<ProtectedRoute path="/home" component={Home} exact />
|
|
||||||
<ProtectedRoute
|
|
||||||
path="/customers"
|
|
||||||
component={CustomersPage}
|
|
||||||
exact
|
|
||||||
/>
|
|
||||||
<ProtectedRoute
|
|
||||||
path="/history"
|
|
||||||
component={HistoryPage}
|
|
||||||
exact
|
|
||||||
/>
|
|
||||||
</IonRouterOutlet>
|
|
||||||
<IonTabBar slot="bottom">
|
|
||||||
<IonTabButton tab="home" href="/home">
|
|
||||||
<IonIcon icon={homeOutline} />
|
|
||||||
<IonLabel>Home</IonLabel>
|
|
||||||
</IonTabButton>
|
|
||||||
<IonTabButton tab="customers" href="/customers">
|
|
||||||
<IonIcon icon={peopleOutline} />
|
|
||||||
<IonLabel>Customers</IonLabel>
|
|
||||||
</IonTabButton>
|
|
||||||
<IonTabButton tab="history" href="/history">
|
|
||||||
<IonIcon icon={receiptOutline} />
|
|
||||||
<IonLabel>History</IonLabel>
|
|
||||||
</IonTabButton>
|
|
||||||
</IonTabBar>
|
|
||||||
</IonTabs>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Root Redirect */}
|
|
||||||
<Route exact path="/" render={() => <Redirect to="/home" />} />
|
<Route exact path="/" render={() => <Redirect to="/home" />} />
|
||||||
</IonRouterOutlet>
|
</IonRouterOutlet>
|
||||||
</IonReactRouter>
|
</IonReactRouter>
|
||||||
</AuthProvider>
|
|
||||||
</IonApp>
|
</IonApp>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 771 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 752 KiB |
@@ -1,130 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { IonIcon, IonItem, IonLabel, IonNote } from '@ionic/react';
|
|
||||||
import {
|
|
||||||
receiptOutline,
|
|
||||||
personAddOutline,
|
|
||||||
cashOutline,
|
|
||||||
chevronForwardOutline,
|
|
||||||
} from 'ionicons/icons';
|
|
||||||
import { formatMoney } from '../utils/money';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
import { formatDisplayDate } from '../utils/dates';
|
|
||||||
|
|
||||||
export type ActivityType = 'sale' | 'credit' | 'repayment';
|
|
||||||
|
|
||||||
export interface ActivityItem {
|
|
||||||
id: string;
|
|
||||||
type: ActivityType;
|
|
||||||
amount: number;
|
|
||||||
date: string;
|
|
||||||
customerName?: string;
|
|
||||||
note?: string;
|
|
||||||
customerId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActivityRowProps {
|
|
||||||
item: ActivityItem;
|
|
||||||
onClick?: () => void;
|
|
||||||
showDate?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ActivityRow: React.FC<ActivityRowProps> = ({
|
|
||||||
item,
|
|
||||||
onClick,
|
|
||||||
showDate = false,
|
|
||||||
}) => {
|
|
||||||
const { profile } = useAuth();
|
|
||||||
const currency = profile?.currency || 'USD';
|
|
||||||
|
|
||||||
const getIcon = () => {
|
|
||||||
switch (item.type) {
|
|
||||||
case 'sale':
|
|
||||||
return receiptOutline;
|
|
||||||
case 'credit':
|
|
||||||
return personAddOutline;
|
|
||||||
case 'repayment':
|
|
||||||
return cashOutline;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTitle = () => {
|
|
||||||
switch (item.type) {
|
|
||||||
case 'sale':
|
|
||||||
return 'Cash Sale';
|
|
||||||
case 'credit':
|
|
||||||
return `Credit: ${item.customerName || 'Customer'}`;
|
|
||||||
case 'repayment':
|
|
||||||
return `Repayment: ${item.customerName || 'Customer'}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAmountColor = () => {
|
|
||||||
switch (item.type) {
|
|
||||||
case 'sale':
|
|
||||||
return 'var(--color-text-primary)';
|
|
||||||
case 'credit':
|
|
||||||
return 'var(--ion-color-primary)';
|
|
||||||
case 'repayment':
|
|
||||||
return 'var(--ion-color-success)';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonItem
|
|
||||||
lines="none"
|
|
||||||
onClick={onClick}
|
|
||||||
detail={false}
|
|
||||||
style={{
|
|
||||||
'--background': 'transparent',
|
|
||||||
'--padding-start': '0',
|
|
||||||
'--padding-end': '0',
|
|
||||||
'--inner-padding-end': '0',
|
|
||||||
cursor: onClick ? 'pointer' : 'default',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="icon-container"
|
|
||||||
slot="start"
|
|
||||||
style={{ marginRight: '12px' }}
|
|
||||||
>
|
|
||||||
<IonIcon icon={getIcon()} />
|
|
||||||
</div>
|
|
||||||
<IonLabel>
|
|
||||||
<h3 className="semibold" style={{ margin: '0', fontSize: '15px' }}>
|
|
||||||
{getTitle()}
|
|
||||||
</h3>
|
|
||||||
<p className="secondary-text" style={{ margin: '2px 0 0 0' }}>
|
|
||||||
{showDate ? formatDisplayDate(item.date) : item.note || 'No notes'}
|
|
||||||
</p>
|
|
||||||
</IonLabel>
|
|
||||||
<div
|
|
||||||
slot="end"
|
|
||||||
style={{ textAlign: 'right', display: 'flex', alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
<IonNote
|
|
||||||
className="tabular"
|
|
||||||
style={{
|
|
||||||
fontSize: '15px',
|
|
||||||
color: getAmountColor(),
|
|
||||||
fontWeight: '400',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.type === 'credit' ? '-' : '+'}
|
|
||||||
{formatMoney(item.amount, currency)}
|
|
||||||
</IonNote>
|
|
||||||
{onClick && (
|
|
||||||
<IonIcon
|
|
||||||
icon={chevronForwardOutline}
|
|
||||||
style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
marginLeft: '8px',
|
|
||||||
color: 'var(--color-text-tertiary)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</IonItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ActivityRow;
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IonProgressBar } from '@ionic/react';
|
||||||
|
|
||||||
|
interface CustomProgressBarProps {
|
||||||
|
value: number; // 0 to 1
|
||||||
|
label?: string;
|
||||||
|
statusText?: string;
|
||||||
|
color?: string; // Hex, rgb, or css variable
|
||||||
|
showPercent?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomProgressBar: React.FC<CustomProgressBarProps> = ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
statusText,
|
||||||
|
color = 'var(--ion-color-primary)',
|
||||||
|
showPercent = true,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
const percentage = Math.round(Math.min(Math.max(value, 0), 1) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', ...style }}>
|
||||||
|
{/* Label and Status */}
|
||||||
|
{(label || statusText || showPercent) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontWeight: '600',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--ion-color-medium)' }}>
|
||||||
|
{label}{' '}
|
||||||
|
{statusText && (
|
||||||
|
<span style={{ fontWeight: 'normal', opacity: 0.85 }}>
|
||||||
|
• {statusText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{showPercent && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: color,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
fontFamily: 'ui-monospace, monospace',
|
||||||
|
fontWeight: '700',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{percentage}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress Track */}
|
||||||
|
<IonProgressBar
|
||||||
|
value={value}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
height: '6px',
|
||||||
|
'--background': 'rgba(148, 163, 184, 0.1)',
|
||||||
|
'--progress-background': color,
|
||||||
|
borderRadius: '10px',
|
||||||
|
transition: 'value 0.3s ease',
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomProgressBar;
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { IonIcon, IonItem, IonLabel, IonNote } from '@ionic/react';
|
|
||||||
import { chevronForwardOutline } from 'ionicons/icons';
|
|
||||||
import { formatMoney } from '../utils/money';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
|
|
||||||
interface CustomerRowProps {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
balance: number;
|
|
||||||
phone?: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomerRow: React.FC<CustomerRowProps> = ({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
balance,
|
|
||||||
phone,
|
|
||||||
onClick,
|
|
||||||
}) => {
|
|
||||||
const { profile } = useAuth();
|
|
||||||
const currency = profile?.currency || 'USD';
|
|
||||||
|
|
||||||
const getInitials = (name: string) => {
|
|
||||||
return name
|
|
||||||
.split(' ')
|
|
||||||
.map((n) => n[0])
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
.slice(0, 2);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonItem
|
|
||||||
lines="none"
|
|
||||||
onClick={onClick}
|
|
||||||
detail={false}
|
|
||||||
style={{
|
|
||||||
'--background': 'transparent',
|
|
||||||
'--padding-start': '0',
|
|
||||||
'--padding-end': '0',
|
|
||||||
'--inner-padding-end': '0',
|
|
||||||
cursor: 'pointer',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="icon-container bold"
|
|
||||||
slot="start"
|
|
||||||
style={{
|
|
||||||
marginRight: '16px',
|
|
||||||
width: '44px',
|
|
||||||
height: '44px',
|
|
||||||
fontSize: '14px',
|
|
||||||
color: 'var(--ion-color-primary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getInitials(name)}
|
|
||||||
</div>
|
|
||||||
<IonLabel>
|
|
||||||
<h3 className="semibold" style={{ margin: '0', fontSize: '16px' }}>
|
|
||||||
{name}
|
|
||||||
</h3>
|
|
||||||
{phone && (
|
|
||||||
<p className="secondary-text" style={{ margin: '2px 0 0 0' }}>
|
|
||||||
{phone}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</IonLabel>
|
|
||||||
<div
|
|
||||||
slot="end"
|
|
||||||
style={{ textAlign: 'right', display: 'flex', alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonNote
|
|
||||||
className="tabular"
|
|
||||||
style={{
|
|
||||||
fontSize: '16px',
|
|
||||||
color:
|
|
||||||
balance > 0
|
|
||||||
? 'var(--ion-color-danger)'
|
|
||||||
: 'var(--ion-color-success)',
|
|
||||||
fontWeight: '400',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatMoney(balance, currency)}
|
|
||||||
</IonNote>
|
|
||||||
<span
|
|
||||||
className="tertiary-text"
|
|
||||||
style={{ fontSize: '10px', marginTop: '2px' }}
|
|
||||||
>
|
|
||||||
{balance > 0 ? 'OUTSTANDING' : 'CLEARED'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<IonIcon
|
|
||||||
icon={chevronForwardOutline}
|
|
||||||
style={{
|
|
||||||
fontSize: '16px',
|
|
||||||
marginLeft: '12px',
|
|
||||||
color: 'var(--color-text-tertiary)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</IonItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CustomerRow;
|
|
||||||
@@ -1,77 +1,30 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { IonIcon, IonButton } from '@ionic/react';
|
import { IonButton, IonIcon } from '@ionic/react';
|
||||||
|
import { sparklesOutline } from 'ionicons/icons';
|
||||||
|
|
||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
icon: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
message: string;
|
||||||
actionText?: string;
|
actionLabel: string;
|
||||||
onAction?: () => void;
|
onAction: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||||
icon,
|
|
||||||
title,
|
title,
|
||||||
description,
|
message,
|
||||||
actionText,
|
actionLabel,
|
||||||
onAction,
|
onAction,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="empty-state-card home-empty-state-card">
|
||||||
className="widget-card"
|
<div className="empty-state-icon">
|
||||||
style={{
|
<IonIcon icon={sparklesOutline} />
|
||||||
padding: '40px 20px',
|
|
||||||
alignItems: 'center',
|
|
||||||
textAlign: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="icon-container"
|
|
||||||
style={{
|
|
||||||
width: '64px',
|
|
||||||
height: '64px',
|
|
||||||
borderRadius: '20px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
background: 'var(--color-surface-secondary)',
|
|
||||||
color: 'var(--color-text-tertiary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon icon={icon} style={{ fontSize: '32px' }} />
|
|
||||||
</div>
|
</div>
|
||||||
<h3
|
<h2>{title}</h2>
|
||||||
style={{
|
<p>{message}</p>
|
||||||
margin: '0 0 8px 0',
|
<IonButton className="home-empty-state-button" onClick={onAction}>
|
||||||
fontSize: '18px',
|
{actionLabel}
|
||||||
fontWeight: '700',
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
{description && (
|
|
||||||
<p
|
|
||||||
className="secondary-text"
|
|
||||||
style={{ margin: '0 0 24px 0', maxWidth: '240px' }}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{actionText && onAction && (
|
|
||||||
<IonButton
|
|
||||||
onClick={onAction}
|
|
||||||
shape="round"
|
|
||||||
fill="solid"
|
|
||||||
style={{
|
|
||||||
height: '44px',
|
|
||||||
'--border-radius': '999px',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: '700',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{actionText}
|
|
||||||
</IonButton>
|
</IonButton>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IonButton, IonIcon, IonSpinner } from '@ionic/react';
|
||||||
|
import {
|
||||||
|
checkmarkOutline,
|
||||||
|
ellipseOutline,
|
||||||
|
flameOutline,
|
||||||
|
trashOutline,
|
||||||
|
} from 'ionicons/icons';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import type { Tables } from '../database.types';
|
||||||
|
|
||||||
|
interface HabitCardProps {
|
||||||
|
habit: Tables<'habits'>;
|
||||||
|
completedToday: boolean;
|
||||||
|
currentStreak: number;
|
||||||
|
completionRate: number;
|
||||||
|
onToggle: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
busy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HabitCard: React.FC<HabitCardProps> = ({
|
||||||
|
habit,
|
||||||
|
completedToday,
|
||||||
|
currentStreak,
|
||||||
|
completionRate,
|
||||||
|
onToggle,
|
||||||
|
onDelete,
|
||||||
|
busy,
|
||||||
|
}) => {
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`habit-card habit-card--editorial ${completedToday ? 'is-complete' : ''}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => history.push(`/habit/${habit.id}`)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
history.push(`/habit/${habit.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={`View details for ${habit.name}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`habit-check-button ${completedToday ? 'is-complete' : ''}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={
|
||||||
|
completedToday
|
||||||
|
? `Mark ${habit.name} as not done`
|
||||||
|
: `Mark ${habit.name} as done`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<IonSpinner name="crescent" />
|
||||||
|
) : completedToday ? (
|
||||||
|
<IonIcon icon={checkmarkOutline} />
|
||||||
|
) : (
|
||||||
|
<IonIcon icon={ellipseOutline} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="habit-card-main">
|
||||||
|
<div className="habit-copy">
|
||||||
|
<div className="habit-copy-topline">
|
||||||
|
<h3>{habit.name}</h3>
|
||||||
|
<span
|
||||||
|
className="habit-color-pill"
|
||||||
|
style={{ background: habit.color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{habit.target_description ||
|
||||||
|
'Show up today and keep the chain alive.'}
|
||||||
|
</p>
|
||||||
|
<div className="habit-metrics-row">
|
||||||
|
<span>
|
||||||
|
<IonIcon icon={flameOutline} /> {currentStreak} streak
|
||||||
|
</span>
|
||||||
|
<span>{completionRate}% this week</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
className="habit-delete-button"
|
||||||
|
fill="clear"
|
||||||
|
color="medium"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<IonIcon icon={trashOutline} />
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HabitCard;
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IonButton,
|
||||||
|
IonButtons,
|
||||||
|
IonContent,
|
||||||
|
IonHeader,
|
||||||
|
IonInput,
|
||||||
|
IonItem,
|
||||||
|
IonLabel,
|
||||||
|
IonModal,
|
||||||
|
IonTextarea,
|
||||||
|
IonTitle,
|
||||||
|
IonToolbar,
|
||||||
|
} from '@ionic/react';
|
||||||
|
|
||||||
|
interface HabitFormModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onSubmit: (values: {
|
||||||
|
name: string;
|
||||||
|
targetDescription: string;
|
||||||
|
color: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
initialValues?: {
|
||||||
|
name: string;
|
||||||
|
targetDescription: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'#7c3aed',
|
||||||
|
'#0f766e',
|
||||||
|
'#ea580c',
|
||||||
|
'#dc2626',
|
||||||
|
'#2563eb',
|
||||||
|
'#059669',
|
||||||
|
];
|
||||||
|
|
||||||
|
const HabitFormModal: React.FC<HabitFormModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onDismiss,
|
||||||
|
onSubmit,
|
||||||
|
initialValues,
|
||||||
|
}) => {
|
||||||
|
const isEditing = !!initialValues;
|
||||||
|
const [name, setName] = useState(initialValues?.name ?? '');
|
||||||
|
const [targetDescription, setTargetDescription] = useState(
|
||||||
|
initialValues?.targetDescription ?? ''
|
||||||
|
);
|
||||||
|
const [color, setColor] = useState(initialValues?.color ?? COLORS[0]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const canSubmit = useMemo(
|
||||||
|
() => name.trim().length > 0 && name.trim().length <= 80,
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setName(initialValues?.name ?? '');
|
||||||
|
setTargetDescription(initialValues?.targetDescription ?? '');
|
||||||
|
setColor(initialValues?.color ?? COLORS[0]);
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
}, [isOpen, initialValues]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setError('');
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSubmit({
|
||||||
|
name: name.trim(),
|
||||||
|
targetDescription: targetDescription.trim(),
|
||||||
|
color,
|
||||||
|
});
|
||||||
|
setName('');
|
||||||
|
setTargetDescription('');
|
||||||
|
setColor(COLORS[0]);
|
||||||
|
onDismiss();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to save habit.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonModal isOpen={isOpen} onDidDismiss={onDismiss}>
|
||||||
|
<IonHeader className="ion-no-border">
|
||||||
|
<IonToolbar>
|
||||||
|
<IonTitle>{isEditing ? 'Edit Habit' : 'New Habit'}</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
<IonButton onClick={onDismiss}>Close</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent className="ion-padding">
|
||||||
|
<div className="habit-form-grid">
|
||||||
|
<IonItem lines="none" className="auth-input-item">
|
||||||
|
<IonInput
|
||||||
|
label="Habit name"
|
||||||
|
labelPlacement="stacked"
|
||||||
|
value={name}
|
||||||
|
maxlength={80}
|
||||||
|
placeholder="Drink water"
|
||||||
|
onIonInput={(e) => setName(e.detail.value ?? '')}
|
||||||
|
/>
|
||||||
|
</IonItem>
|
||||||
|
|
||||||
|
<IonItem lines="none" className="auth-input-item">
|
||||||
|
<IonTextarea
|
||||||
|
label="Target or note"
|
||||||
|
labelPlacement="stacked"
|
||||||
|
value={targetDescription}
|
||||||
|
placeholder="8 glasses, 20 minutes, 30 pushups..."
|
||||||
|
autoGrow
|
||||||
|
onIonInput={(e) => setTargetDescription(e.detail.value ?? '')}
|
||||||
|
/>
|
||||||
|
</IonItem>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<IonLabel className="color-label">Color</IonLabel>
|
||||||
|
<div className="habit-color-row">
|
||||||
|
{COLORS.map((swatch) => (
|
||||||
|
<button
|
||||||
|
key={swatch}
|
||||||
|
type="button"
|
||||||
|
className={`habit-color-dot ${color === swatch ? 'selected' : ''}`}
|
||||||
|
style={{ background: swatch }}
|
||||||
|
onClick={() => setColor(swatch)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className="auth-feedback error-text">{error}</p> : null}
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit || saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : isEditing ? 'Save Changes' : 'Create Habit'}
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
</IonContent>
|
||||||
|
</IonModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HabitFormModal;
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { IonIcon } from '@ionic/react';
|
|
||||||
import { formatMoney } from '../utils/money';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
|
|
||||||
interface MetricCardProps {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
icon?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
fullWidth?: boolean;
|
|
||||||
loading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MetricCard: React.FC<MetricCardProps> = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
icon,
|
|
||||||
subtitle,
|
|
||||||
fullWidth = false,
|
|
||||||
loading = false,
|
|
||||||
}) => {
|
|
||||||
const { profile } = useAuth();
|
|
||||||
const currency = profile?.currency || 'USD';
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="widget-card"
|
|
||||||
style={{
|
|
||||||
width: fullWidth ? '100%' : 'auto',
|
|
||||||
minHeight: fullWidth ? '160px' : '100px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="skeleton-text"
|
|
||||||
style={{
|
|
||||||
width: '40%',
|
|
||||||
height: '12px',
|
|
||||||
background: 'var(--color-surface-secondary)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
marginBottom: '12px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="skeleton-text"
|
|
||||||
style={{
|
|
||||||
width: '70%',
|
|
||||||
height: '32px',
|
|
||||||
background: 'var(--color-surface-secondary)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="widget-card"
|
|
||||||
style={{
|
|
||||||
width: fullWidth ? '100%' : 'auto',
|
|
||||||
minHeight: fullWidth ? '160px' : '100px',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span className="tertiary-text">{label}</span>
|
|
||||||
<div
|
|
||||||
className="tabular"
|
|
||||||
style={{
|
|
||||||
fontSize: fullWidth ? '34px' : '24px',
|
|
||||||
fontWeight: '400',
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
marginTop: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatMoney(value, currency)}
|
|
||||||
</div>
|
|
||||||
{subtitle && <div className="secondary-text">{subtitle}</div>}
|
|
||||||
</div>
|
|
||||||
{icon && (
|
|
||||||
<div className="icon-container">
|
|
||||||
<IonIcon icon={icon} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MetricCard;
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { IonInput, IonLabel } from '@ionic/react';
|
|
||||||
import { sanitizeMoneyInput, getCurrencySymbol } from '../utils/money';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
|
|
||||||
interface MoneyInputProps {
|
|
||||||
value: string;
|
|
||||||
onValueChange: (value: string) => void;
|
|
||||||
label?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MoneyInput: React.FC<MoneyInputProps> = ({
|
|
||||||
value,
|
|
||||||
onValueChange,
|
|
||||||
label,
|
|
||||||
placeholder = '0.00',
|
|
||||||
error,
|
|
||||||
}) => {
|
|
||||||
const { profile } = useAuth();
|
|
||||||
const symbol = getCurrencySymbol(profile?.currency || 'USD');
|
|
||||||
|
|
||||||
const handleChange = (e: CustomEvent) => {
|
|
||||||
const rawValue = e.detail.value || '';
|
|
||||||
const sanitized = sanitizeMoneyInput(rawValue);
|
|
||||||
onValueChange(sanitized);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="money-input-container">
|
|
||||||
{label && (
|
|
||||||
<IonLabel
|
|
||||||
position="stacked"
|
|
||||||
className="semibold"
|
|
||||||
style={{ marginBottom: '8px', display: 'block', fontSize: '13px' }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</IonLabel>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="input-wrapper"
|
|
||||||
style={{
|
|
||||||
border: error ? '1px solid var(--ion-color-danger)' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="bold"
|
|
||||||
style={{
|
|
||||||
marginRight: '8px',
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
fontSize: '18px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{symbol}
|
|
||||||
</span>
|
|
||||||
<IonInput
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={value}
|
|
||||||
placeholder={placeholder}
|
|
||||||
onIonChange={handleChange}
|
|
||||||
style={{
|
|
||||||
fontSize: '20px',
|
|
||||||
fontWeight: '700',
|
|
||||||
'--padding-top': '0',
|
|
||||||
'--padding-bottom': '0',
|
|
||||||
}}
|
|
||||||
className="tabular"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<IonLabel
|
|
||||||
color="danger"
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
marginTop: '4px',
|
|
||||||
display: 'block',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</IonLabel>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MoneyInput;
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
IonHeader,
|
||||||
|
IonToolbar,
|
||||||
|
IonTitle,
|
||||||
|
IonButtons,
|
||||||
|
IonBackButton,
|
||||||
|
useIonViewWillEnter,
|
||||||
|
} from '@ionic/react';
|
||||||
|
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
showBackButton?: boolean;
|
||||||
|
defaultBackHref?: string;
|
||||||
|
endActions?: React.ReactNode;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
toolbarStyle?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||||
|
title,
|
||||||
|
showBackButton = false,
|
||||||
|
defaultBackHref = '/home',
|
||||||
|
endActions,
|
||||||
|
style,
|
||||||
|
toolbarStyle,
|
||||||
|
}) => {
|
||||||
|
// Use Ionic lifecycle hook to set status bar style automatically for pages using PageHeader
|
||||||
|
useIonViewWillEnter(() => {
|
||||||
|
setStatusBarStyle(Style.Dark); // Dark text/icons for light-colored headers
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonHeader className="ion-no-border" style={style}>
|
||||||
|
<IonToolbar
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--background': '#ffffff',
|
||||||
|
'--color': 'var(--ion-color-dark)',
|
||||||
|
'--border-width': '0 0 1px 0',
|
||||||
|
'--border-color': 'rgba(0, 0, 0, 0.05)',
|
||||||
|
...toolbarStyle,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showBackButton && (
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonBackButton defaultHref={defaultBackHref} />
|
||||||
|
</IonButtons>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IonTitle
|
||||||
|
style={{
|
||||||
|
fontWeight: '800',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
letterSpacing: '-0.3px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</IonTitle>
|
||||||
|
|
||||||
|
{endActions && <IonButtons slot="end">{endActions}</IonButtons>}
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageHeader;
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Route, Redirect, RouteProps } from 'react-router-dom';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
|
|
||||||
interface ProtectedRouteProps extends RouteProps {
|
|
||||||
component: React.ComponentType<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
|
||||||
component: Component,
|
|
||||||
...rest
|
|
||||||
}) => {
|
|
||||||
const { user, loading } = useAuth();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Route
|
|
||||||
{...rest}
|
|
||||||
render={(props) => {
|
|
||||||
if (loading) return null; // AuthProvider already handles spinner, but double safety
|
|
||||||
|
|
||||||
return user ? <Component {...props} /> : <Redirect to="/auth" />;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProtectedRoute;
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IonIcon } from '@ionic/react';
|
||||||
|
|
||||||
|
interface RevisionInstrumentCardProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
icon: string;
|
||||||
|
iconColor: string;
|
||||||
|
iconBgColor: string;
|
||||||
|
onClick: () => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RevisionInstrumentCard: React.FC<RevisionInstrumentCardProps> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
|
iconColor,
|
||||||
|
iconBgColor,
|
||||||
|
onClick,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
background: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '14px 10px',
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: 'none',
|
||||||
|
transition: 'transform 0.15s ease',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
className="ion-activatable"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
background: iconBgColor,
|
||||||
|
color: iconColor,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
margin: '0 auto 8px auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IonIcon icon={icon} style={{ fontSize: '18px' }} />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: 'var(--ion-color-dark)',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{subtitle && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
display: 'block',
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RevisionInstrumentCard;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IonProgressBar } from '@ionic/react';
|
||||||
|
|
||||||
|
interface StatsHabitCardProps {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
completedCount: number;
|
||||||
|
totalDays: number;
|
||||||
|
streak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatsHabitCard: React.FC<StatsHabitCardProps> = ({
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
completedCount,
|
||||||
|
totalDays,
|
||||||
|
streak,
|
||||||
|
}) => {
|
||||||
|
const ratio = totalDays > 0 ? completedCount / totalDays : 0;
|
||||||
|
const percentage = Math.round(ratio * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stats-card">
|
||||||
|
<div className="stats-card-header">
|
||||||
|
<div>
|
||||||
|
<h3>{name}</h3>
|
||||||
|
<p>
|
||||||
|
{completedCount} of {totalDays} days completed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stats-streak-badge" style={{ color }}>
|
||||||
|
{streak}🔥
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IonProgressBar
|
||||||
|
value={ratio}
|
||||||
|
style={{ '--progress-background': color } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
<div className="stats-card-footer">
|
||||||
|
<span>{percentage}% consistency</span>
|
||||||
|
<span>Current streak {streak}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatsHabitCard;
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import React, {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { User } 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;
|
|
||||||
profile: Profile | null;
|
|
||||||
loading: boolean;
|
|
||||||
refreshProfile: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const fetchProfile = useCallback(async (currentUser: User) => {
|
|
||||||
try {
|
|
||||||
const fallbackProfile = {
|
|
||||||
id: currentUser.id,
|
|
||||||
full_name:
|
|
||||||
typeof currentUser.user_metadata?.full_name === 'string'
|
|
||||||
? currentUser.user_metadata.full_name
|
|
||||||
: '',
|
|
||||||
shop_name: 'My Shop',
|
|
||||||
currency: 'USD',
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.upsert(fallbackProfile, { onConflict: 'id', ignoreDuplicates: true })
|
|
||||||
.select('*')
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error fetching profile:', error);
|
|
||||||
setProfile({
|
|
||||||
...fallbackProfile,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProfile(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching profile:', err);
|
|
||||||
setProfile(null);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshProfile = async () => {
|
|
||||||
if (user) {
|
|
||||||
await fetchProfile(user);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
const initialiseSession = async () => {
|
|
||||||
const timeout = window.setTimeout(() => {
|
|
||||||
if (mounted) {
|
|
||||||
console.warn('Auth initialisation timed out; showing app shell.');
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, 8000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
data: { session },
|
|
||||||
error,
|
|
||||||
} = await supabase.auth.getSession();
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error restoring auth session:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentUser = session?.user ?? null;
|
|
||||||
setUser(currentUser);
|
|
||||||
|
|
||||||
if (currentUser) {
|
|
||||||
await fetchProfile(currentUser);
|
|
||||||
} else {
|
|
||||||
setProfile(null);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error restoring auth session:', err);
|
|
||||||
if (mounted) {
|
|
||||||
setUser(null);
|
|
||||||
setProfile(null);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
window.clearTimeout(timeout);
|
|
||||||
if (mounted) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void initialiseSession();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: { subscription },
|
|
||||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
|
||||||
const currentUser = session?.user ?? null;
|
|
||||||
setUser(currentUser);
|
|
||||||
|
|
||||||
if (currentUser) {
|
|
||||||
void fetchProfile(currentUser);
|
|
||||||
} else {
|
|
||||||
setProfile(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
subscription.unsubscribe();
|
|
||||||
};
|
|
||||||
}, [fetchProfile]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '100vh',
|
|
||||||
background: 'var(--color-bg, #f0f4ff)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonSpinner name="crescent" color="primary" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={{ user, profile, loading, refreshProfile }}>
|
|
||||||
{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;
|
|
||||||
};
|
|
||||||
+82
-132
@@ -14,171 +14,121 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
public: {
|
public: {
|
||||||
Tables: {
|
Tables: {
|
||||||
credit_customers: {
|
habit_completions: {
|
||||||
Row: {
|
Row: {
|
||||||
|
completed_on: string
|
||||||
created_at: string
|
created_at: string
|
||||||
|
habit_id: string
|
||||||
id: string
|
id: string
|
||||||
name: string
|
|
||||||
notes: string | null
|
|
||||||
phone: string | null
|
|
||||||
updated_at: string
|
|
||||||
user_id: string
|
user_id: string
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
|
completed_on: string
|
||||||
created_at?: string
|
created_at?: string
|
||||||
|
habit_id: string
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
|
||||||
notes?: string | null
|
|
||||||
phone?: string | null
|
|
||||||
updated_at?: string
|
|
||||||
user_id: string
|
user_id: string
|
||||||
}
|
}
|
||||||
Update: {
|
Update: {
|
||||||
|
completed_on?: string
|
||||||
created_at?: string
|
created_at?: string
|
||||||
|
habit_id?: string
|
||||||
|
id?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "habit_completions_habit_id_fkey"
|
||||||
|
columns: ["habit_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "habits"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
habits: {
|
||||||
|
Row: {
|
||||||
|
color: string
|
||||||
|
created_at: string
|
||||||
|
fcm_token: string | null
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
target_description: string | null
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
color?: string
|
||||||
|
created_at?: string
|
||||||
|
fcm_token?: string | null
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
target_description?: string | null
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
color?: string
|
||||||
|
created_at?: string
|
||||||
|
fcm_token?: string | null
|
||||||
id?: string
|
id?: string
|
||||||
name?: string
|
name?: string
|
||||||
notes?: string | null
|
target_description?: string | null
|
||||||
phone?: string | null
|
|
||||||
updated_at?: string
|
|
||||||
user_id?: string
|
user_id?: string
|
||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: []
|
||||||
}
|
}
|
||||||
credits: {
|
notes: {
|
||||||
|
Row: {
|
||||||
|
content: string
|
||||||
|
created_at: string
|
||||||
|
id: string
|
||||||
|
is_favorite: boolean
|
||||||
|
title: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
content: string
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
is_favorite?: boolean
|
||||||
|
title: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
content?: string
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
is_favorite?: boolean
|
||||||
|
title?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
todos: {
|
||||||
Row: {
|
Row: {
|
||||||
amount: number
|
|
||||||
created_at: string
|
created_at: string
|
||||||
credit_date: string
|
|
||||||
customer_id: string
|
|
||||||
description: string | null
|
description: string | null
|
||||||
|
due_date: string | null
|
||||||
id: string
|
id: string
|
||||||
updated_at: string
|
is_completed: boolean
|
||||||
|
title: string
|
||||||
user_id: string
|
user_id: string
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
amount: number
|
|
||||||
created_at?: string
|
created_at?: string
|
||||||
credit_date?: string
|
|
||||||
customer_id: string
|
|
||||||
description?: string | null
|
description?: string | null
|
||||||
|
due_date?: string | null
|
||||||
id?: string
|
id?: string
|
||||||
updated_at?: string
|
is_completed?: boolean
|
||||||
user_id: string
|
title: string
|
||||||
}
|
|
||||||
Update: {
|
|
||||||
amount?: number
|
|
||||||
created_at?: string
|
|
||||||
credit_date?: string
|
|
||||||
customer_id?: string
|
|
||||||
description?: string | null
|
|
||||||
id?: string
|
|
||||||
updated_at?: string
|
|
||||||
user_id?: string
|
user_id?: string
|
||||||
}
|
}
|
||||||
Relationships: [
|
|
||||||
{
|
|
||||||
foreignKeyName: "credits_customer_id_fkey"
|
|
||||||
columns: ["customer_id"]
|
|
||||||
isOneToOne: false
|
|
||||||
referencedRelation: "credit_customers"
|
|
||||||
referencedColumns: ["id"]
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
profiles: {
|
|
||||||
Row: {
|
|
||||||
created_at: string
|
|
||||||
currency: string
|
|
||||||
full_name: string | null
|
|
||||||
id: string
|
|
||||||
shop_name: string | null
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
Insert: {
|
|
||||||
created_at?: string
|
|
||||||
currency?: string
|
|
||||||
full_name?: string | null
|
|
||||||
id: string
|
|
||||||
shop_name?: string | null
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
Update: {
|
Update: {
|
||||||
created_at?: string
|
created_at?: string
|
||||||
currency?: string
|
description?: string | null
|
||||||
full_name?: string | null
|
due_date?: string | null
|
||||||
id?: string
|
id?: string
|
||||||
shop_name?: string | null
|
is_completed?: boolean
|
||||||
updated_at?: string
|
title?: string
|
||||||
}
|
|
||||||
Relationships: []
|
|
||||||
}
|
|
||||||
repayments: {
|
|
||||||
Row: {
|
|
||||||
amount: number
|
|
||||||
created_at: string
|
|
||||||
customer_id: string
|
|
||||||
id: string
|
|
||||||
note: string | null
|
|
||||||
repayment_date: string
|
|
||||||
updated_at: string
|
|
||||||
user_id: string
|
|
||||||
}
|
|
||||||
Insert: {
|
|
||||||
amount: number
|
|
||||||
created_at?: string
|
|
||||||
customer_id: string
|
|
||||||
id?: string
|
|
||||||
note?: string | null
|
|
||||||
repayment_date?: string
|
|
||||||
updated_at?: string
|
|
||||||
user_id: string
|
|
||||||
}
|
|
||||||
Update: {
|
|
||||||
amount?: number
|
|
||||||
created_at?: string
|
|
||||||
customer_id?: string
|
|
||||||
id?: string
|
|
||||||
note?: string | null
|
|
||||||
repayment_date?: string
|
|
||||||
updated_at?: string
|
|
||||||
user_id?: string
|
|
||||||
}
|
|
||||||
Relationships: [
|
|
||||||
{
|
|
||||||
foreignKeyName: "repayments_customer_id_fkey"
|
|
||||||
columns: ["customer_id"]
|
|
||||||
isOneToOne: false
|
|
||||||
referencedRelation: "credit_customers"
|
|
||||||
referencedColumns: ["id"]
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
sales: {
|
|
||||||
Row: {
|
|
||||||
amount: number
|
|
||||||
created_at: string
|
|
||||||
id: string
|
|
||||||
note: string | null
|
|
||||||
sale_date: string
|
|
||||||
updated_at: string
|
|
||||||
user_id: string
|
|
||||||
}
|
|
||||||
Insert: {
|
|
||||||
amount: number
|
|
||||||
created_at?: string
|
|
||||||
id?: string
|
|
||||||
note?: string | null
|
|
||||||
sale_date?: string
|
|
||||||
updated_at?: string
|
|
||||||
user_id: string
|
|
||||||
}
|
|
||||||
Update: {
|
|
||||||
amount?: number
|
|
||||||
created_at?: string
|
|
||||||
id?: string
|
|
||||||
note?: string | null
|
|
||||||
sale_date?: string
|
|
||||||
updated_at?: string
|
|
||||||
user_id?: string
|
user_id?: string
|
||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: []
|
||||||
|
|||||||
+5
-10
@@ -5,7 +5,7 @@ import { Capacitor } from '@capacitor/core';
|
|||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
// ── Ionic initialisation ──────────────────────────────────────────────────────
|
// ── Ionic initialisation ──────────────────────────────────────────────────────
|
||||||
// ?mode=md|ios overrides (used by Appcakes preview); otherwise follow the platform.
|
// ?mode=md|ios overrides (used by AppSuite preview); otherwise follow the platform.
|
||||||
const _urlMode = new URLSearchParams(window.location.search).get('mode');
|
const _urlMode = new URLSearchParams(window.location.search).get('mode');
|
||||||
const _platform = Capacitor.getPlatform(); // 'ios' | 'android' | 'web'
|
const _platform = Capacitor.getPlatform(); // 'ios' | 'android' | 'web'
|
||||||
setupIonicReact({
|
setupIonicReact({
|
||||||
@@ -13,7 +13,7 @@ setupIonicReact({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Derive the studio parent origin dynamically so this works in both dev
|
// Derive the studio parent origin dynamically so this works in both dev
|
||||||
// (parent at localhost:3000) and production (parent at studio.appcakes.dev).
|
// (parent at localhost:3000) and production (parent at appcakes.qqura.com).
|
||||||
const studioOrigin = (() => {
|
const studioOrigin = (() => {
|
||||||
try {
|
try {
|
||||||
if (document.referrer) return new URL(document.referrer).origin;
|
if (document.referrer) return new URL(document.referrer).origin;
|
||||||
@@ -49,7 +49,7 @@ if (import.meta.hot) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Session bridge ────────────────────────────────────────────────────────────
|
// ── Session bridge ────────────────────────────────────────────────────────────
|
||||||
// Post auth session to the Appcakes parent frame so the AI can test auth-protected
|
// Post auth session to the AppSuite parent frame so the AI can test auth-protected
|
||||||
// Edge Functions. Uses import.meta.glob so Vite never errors if supabase.ts is absent.
|
// Edge Functions. Uses import.meta.glob so Vite never errors if supabase.ts is absent.
|
||||||
(async () => {
|
(async () => {
|
||||||
const mods = import.meta.glob('./supabase.ts', { eager: false });
|
const mods = import.meta.glob('./supabase.ts', { eager: false });
|
||||||
@@ -73,17 +73,12 @@ if (import.meta.hot) {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
// ── Runtime error reporting ───────────────────────────────────────────────────
|
// ── Runtime error reporting ───────────────────────────────────────────────────
|
||||||
// Forward uncaught errors to the Appcakes dev server so the AI can read them.
|
// Forward uncaught errors to the AppSuite dev server so the AI can read them.
|
||||||
// Extract projectId from the preview URL (/preview/{projectId}/) so errors are
|
|
||||||
// scoped per-project on the server rather than mixed into a global queue.
|
|
||||||
const _previewMatch = window.location.pathname.match(/\/preview\/([^/]+)/);
|
|
||||||
const _projectId = _previewMatch?.[1] ?? null;
|
|
||||||
|
|
||||||
function reportError(message: string, stack?: string) {
|
function reportError(message: string, stack?: string) {
|
||||||
fetch(`${studioOrigin}/api/agent/runtime-error`, {
|
fetch(`${studioOrigin}/api/agent/runtime-error`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ message, stack, projectId: _projectId }),
|
body: JSON.stringify({ message, stack }),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IonButton,
|
||||||
|
IonContent,
|
||||||
|
IonHeader,
|
||||||
|
IonIcon,
|
||||||
|
IonItem,
|
||||||
|
IonLabel,
|
||||||
|
IonPage,
|
||||||
|
IonTitle,
|
||||||
|
IonToolbar,
|
||||||
|
IonText,
|
||||||
|
} from '@ionic/react';
|
||||||
|
import {
|
||||||
|
waterOutline,
|
||||||
|
fitnessOutline,
|
||||||
|
shieldCheckmarkOutline,
|
||||||
|
} from 'ionicons/icons';
|
||||||
|
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
import { Redirect as RedirectRR, useHistory } from 'react-router-dom';
|
||||||
|
import { supabase } from '../supabase';
|
||||||
|
import {
|
||||||
|
Style,
|
||||||
|
setStatusBarBackground,
|
||||||
|
setStatusBarStyle,
|
||||||
|
} from '../utils/statusBar';
|
||||||
|
import { useIonViewWillEnter } from '@ionic/react';
|
||||||
|
import runnerDrinkingWaterImage from '../assets/runner-drinking-water-photorealistic.png';
|
||||||
|
|
||||||
|
const RedirectAny = RedirectRR as any;
|
||||||
|
|
||||||
|
const Account: React.FC = () => {
|
||||||
|
const history = useHistory();
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
|
const [email, setEmail] = useState<string | null>(null);
|
||||||
|
const [signingOut, setSigningOut] = useState(false);
|
||||||
|
const [signOutError, setSignOutError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
supabase.auth.getSession().then(({ data }) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setEmail(data.session?.user.email ?? null);
|
||||||
|
setSessionChecked(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { subscription },
|
||||||
|
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setEmail(session?.user.email ?? null);
|
||||||
|
setSessionChecked(true);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
history.replace('/auth');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
setSignOutError('');
|
||||||
|
setSigningOut(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.signOut();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setSignOutError(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
try {
|
||||||
|
await FirebaseAuthentication.signOut();
|
||||||
|
} catch {
|
||||||
|
// Ignore if no native Google session exists.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmail(null);
|
||||||
|
history.replace('/auth');
|
||||||
|
} catch (error) {
|
||||||
|
setSignOutError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Unable to sign out right now.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSigningOut(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sessionChecked && !email) {
|
||||||
|
return <RedirectAny to="/auth" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader className="ion-no-border">
|
||||||
|
<IonToolbar>
|
||||||
|
<IonTitle>Account</IonTitle>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent fullscreen>
|
||||||
|
<div className="dashboard-shell ion-padding account-shell">
|
||||||
|
<section className="account-hero-card">
|
||||||
|
<div className="account-hero-image-wrap">
|
||||||
|
<img
|
||||||
|
src={runnerDrinkingWaterImage}
|
||||||
|
alt="Runner drinking water after a run"
|
||||||
|
className="account-hero-image"
|
||||||
|
/>
|
||||||
|
<div className="account-hero-overlay" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="account-hero-content">
|
||||||
|
<span className="eyebrow">Recovery & account</span>
|
||||||
|
<h1>{email ?? 'Loading...'}</h1>
|
||||||
|
<p>
|
||||||
|
Stay hydrated, stay consistent, and keep your habit history
|
||||||
|
safely synced across sessions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="account-highlight-row">
|
||||||
|
<div className="account-highlight-chip">
|
||||||
|
<IonIcon icon={waterOutline} />
|
||||||
|
<span>Recovery mindset</span>
|
||||||
|
</div>
|
||||||
|
<div className="account-highlight-chip subtle">
|
||||||
|
<IonIcon icon={fitnessOutline} />
|
||||||
|
<span>Built for routines</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="stats-card account-details-card">
|
||||||
|
<IonItem lines="none" className="account-info-item">
|
||||||
|
<IonLabel>
|
||||||
|
<h2>Email</h2>
|
||||||
|
<p>{email ?? '—'}</p>
|
||||||
|
</IonLabel>
|
||||||
|
</IonItem>
|
||||||
|
|
||||||
|
<div className="account-trust-row">
|
||||||
|
<div className="account-trust-icon">
|
||||||
|
<IonIcon icon={shieldCheckmarkOutline} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Your data is synced</h3>
|
||||||
|
<p>
|
||||||
|
Sign in to keep your streaks and progress available whenever
|
||||||
|
you come back.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
className="account-primary-button"
|
||||||
|
routerLink="/home"
|
||||||
|
>
|
||||||
|
Back to Habits
|
||||||
|
</IonButton>
|
||||||
|
{signOutError ? (
|
||||||
|
<IonText color="danger">
|
||||||
|
<p className="auth-feedback">{signOutError}</p>
|
||||||
|
</IonText>
|
||||||
|
) : null}
|
||||||
|
<div></div>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
fill="outline"
|
||||||
|
color="danger"
|
||||||
|
className="account-secondary-button"
|
||||||
|
onClick={handleSignOut}
|
||||||
|
disabled={signingOut}
|
||||||
|
>
|
||||||
|
{signingOut ? 'Signing Out…' : 'Sign Out'}
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Account;
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IonButton,
|
||||||
|
IonCard,
|
||||||
|
IonCardContent,
|
||||||
|
IonContent,
|
||||||
|
IonIcon,
|
||||||
|
IonInput,
|
||||||
|
IonItem,
|
||||||
|
IonLabel,
|
||||||
|
IonPage,
|
||||||
|
IonSegment,
|
||||||
|
IonSegmentButton,
|
||||||
|
IonText,
|
||||||
|
useIonViewWillEnter,
|
||||||
|
} from '@ionic/react';
|
||||||
|
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
import { Redirect, useHistory } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Style,
|
||||||
|
setStatusBarBackground,
|
||||||
|
setStatusBarStyle,
|
||||||
|
} from '../utils/statusBar';
|
||||||
|
import { supabase } from '../supabase';
|
||||||
|
|
||||||
|
const Auth: React.FC = () => {
|
||||||
|
const history = useHistory();
|
||||||
|
const [mode, setMode] = useState<'login' | 'signup'>('login');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [googleLoading, setGoogleLoading] = useState(false);
|
||||||
|
const [appleLoading, setAppleLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [info, setInfo] = useState('');
|
||||||
|
const [verificationMessage, setVerificationMessage] = useState('');
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
|
const [hasSession, setHasSession] = useState(false);
|
||||||
|
|
||||||
|
const showError = (msg: string) => {
|
||||||
|
setError(msg);
|
||||||
|
setTimeout(() => {
|
||||||
|
setError((current) => (current === msg ? '' : current));
|
||||||
|
}, 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
useIonViewWillEnter(() => {
|
||||||
|
setStatusBarStyle(Style.Dark);
|
||||||
|
setStatusBarBackground('#f6f7fb');
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
supabase.auth.getSession().then(({ data }) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setHasSession(Boolean(data.session));
|
||||||
|
setSessionChecked(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { subscription },
|
||||||
|
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setHasSession(Boolean(session));
|
||||||
|
setSessionChecked(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formValid = useMemo(() => {
|
||||||
|
return email.trim().length > 3 && password.length >= 6;
|
||||||
|
}, [email, password]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setError('');
|
||||||
|
setInfo('');
|
||||||
|
setVerificationMessage('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'login') {
|
||||||
|
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signInError) {
|
||||||
|
showError(signInError.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.replace('/home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error: signUpError } = await supabase.auth.signUp({
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signUpError) {
|
||||||
|
showError(signUpError.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.session) {
|
||||||
|
history.replace('/home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVerificationMessage(
|
||||||
|
'Account created! Check your email for a verification link.'
|
||||||
|
);
|
||||||
|
setInfo('Once verified, come back and sign in.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleLogin = async () => {
|
||||||
|
setError('');
|
||||||
|
setInfo('');
|
||||||
|
setVerificationMessage('');
|
||||||
|
setGoogleLoading(true);
|
||||||
|
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
showError(
|
||||||
|
'Google Sign-In is only available in the native app. Use email to sign in here.'
|
||||||
|
);
|
||||||
|
setGoogleLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await FirebaseAuthentication.signInWithGoogle();
|
||||||
|
const idToken = result.credential?.idToken;
|
||||||
|
|
||||||
|
if (!idToken) {
|
||||||
|
showError('Google sign-in did not return an ID token.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: googleAuthError } = await supabase.auth.signInWithIdToken({
|
||||||
|
provider: 'google',
|
||||||
|
token: idToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (googleAuthError) {
|
||||||
|
showError(googleAuthError.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.replace('/home');
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : 'Google sign-in failed.';
|
||||||
|
showError(message);
|
||||||
|
} finally {
|
||||||
|
setGoogleLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAppleLogin = async () => {
|
||||||
|
setError('');
|
||||||
|
setInfo('');
|
||||||
|
setVerificationMessage('');
|
||||||
|
setAppleLoading(true);
|
||||||
|
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
showError(
|
||||||
|
'Apple Sign-In is only available in the native app. Use email to sign in here.'
|
||||||
|
);
|
||||||
|
setAppleLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await FirebaseAuthentication.signInWithApple({
|
||||||
|
skipNativeAuth: true,
|
||||||
|
});
|
||||||
|
const idToken = result.credential?.idToken;
|
||||||
|
const rawNonce = result.credential?.nonce;
|
||||||
|
|
||||||
|
if (!idToken) {
|
||||||
|
showError('Apple sign-in did not return an ID token.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: appleAuthError } = await supabase.auth.signInWithIdToken({
|
||||||
|
provider: 'apple',
|
||||||
|
token: idToken,
|
||||||
|
nonce: rawNonce || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (appleAuthError) {
|
||||||
|
showError(appleAuthError.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.replace('/home');
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : 'Apple sign-in failed.';
|
||||||
|
showError(message);
|
||||||
|
} finally {
|
||||||
|
setAppleLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sessionChecked && hasSession) {
|
||||||
|
return <Redirect to="/home" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonPage>
|
||||||
|
<IonContent fullscreen style={{ '--background': '#f6f7fb' }}>
|
||||||
|
<div className="auth-shell">
|
||||||
|
<div className="auth-hero">
|
||||||
|
<span className="eyebrow">Protect your streaks</span>
|
||||||
|
<h1>Build habits that actually stick.</h1>
|
||||||
|
<p>
|
||||||
|
Track daily wins, recover faster from misses, and see which habits
|
||||||
|
are getting real momentum.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonCard className="auth-card">
|
||||||
|
<IonCardContent>
|
||||||
|
<IonSegment
|
||||||
|
value={mode}
|
||||||
|
onIonChange={(e) =>
|
||||||
|
setMode(e.detail.value as 'login' | 'signup')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IonSegmentButton value="login">
|
||||||
|
<IonLabel>Login</IonLabel>
|
||||||
|
</IonSegmentButton>
|
||||||
|
<IonSegmentButton value="signup">
|
||||||
|
<IonLabel>Sign Up</IonLabel>
|
||||||
|
</IonSegmentButton>
|
||||||
|
</IonSegment>
|
||||||
|
|
||||||
|
{verificationMessage ? (
|
||||||
|
<div className="auth-message-block">
|
||||||
|
<h2>Check your inbox</h2>
|
||||||
|
<p>{verificationMessage}</p>
|
||||||
|
<IonText color="medium">
|
||||||
|
<p>{info}</p>
|
||||||
|
</IonText>
|
||||||
|
<IonButton fill="clear" onClick={() => setMode('login')}>
|
||||||
|
Back to Login
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="auth-form">
|
||||||
|
<IonItem lines="none" className="auth-input-item">
|
||||||
|
<IonInput
|
||||||
|
type="email"
|
||||||
|
label="Email"
|
||||||
|
labelPlacement="stacked"
|
||||||
|
value={email}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
onIonInput={(e) => setEmail(e.detail.value ?? '')}
|
||||||
|
/>
|
||||||
|
</IonItem>
|
||||||
|
|
||||||
|
<IonItem lines="none" className="auth-input-item">
|
||||||
|
<IonInput
|
||||||
|
type="password"
|
||||||
|
label="Password"
|
||||||
|
labelPlacement="stacked"
|
||||||
|
value={password}
|
||||||
|
placeholder="At least 6 characters"
|
||||||
|
onIonInput={(e) => setPassword(e.detail.value ?? '')}
|
||||||
|
/>
|
||||||
|
</IonItem>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<IonText color="danger">
|
||||||
|
<p className="auth-feedback">{error}</p>
|
||||||
|
</IonText>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{info ? (
|
||||||
|
<IonText color="medium">
|
||||||
|
<p className="auth-feedback">{info}</p>
|
||||||
|
</IonText>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
disabled={
|
||||||
|
!formValid || loading || googleLoading || appleLoading
|
||||||
|
}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? 'Please wait…'
|
||||||
|
: mode === 'login'
|
||||||
|
? 'Log In'
|
||||||
|
: 'Create Account'}
|
||||||
|
</IonButton>
|
||||||
|
|
||||||
|
{mode === 'login' ? (
|
||||||
|
<>
|
||||||
|
<div className="auth-divider">
|
||||||
|
<span>or</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
fill="outline"
|
||||||
|
className="google-auth-button"
|
||||||
|
disabled={loading || googleLoading || appleLoading}
|
||||||
|
onClick={handleGoogleLogin}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
slot="start"
|
||||||
|
className="google-svg-wrap"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
fill="#4285F4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
fill="#34A853"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
||||||
|
fill="#FBBC05"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
fill="#EA4335"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{googleLoading ? 'Connecting…' : 'Continue with Google'}
|
||||||
|
</IonButton>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
fill="outline"
|
||||||
|
className="apple-auth-button"
|
||||||
|
disabled={loading || googleLoading || appleLoading}
|
||||||
|
onClick={handleAppleLogin}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
slot="start"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
>
|
||||||
|
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{appleLoading ? 'Connecting…' : 'Continue with Apple'}
|
||||||
|
</IonButton>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</IonCardContent>
|
||||||
|
</IonCard>
|
||||||
|
</div>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Auth;
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
IonContent,
|
|
||||||
IonPage,
|
|
||||||
IonInput,
|
|
||||||
IonButton,
|
|
||||||
IonSpinner,
|
|
||||||
IonIcon,
|
|
||||||
IonText,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import { storefrontOutline } from 'ionicons/icons';
|
|
||||||
import { useHistory } from 'react-router-dom';
|
|
||||||
import { supabase } from '../supabase';
|
|
||||||
|
|
||||||
const AuthPage: React.FC = () => {
|
|
||||||
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
const [messageType, setMessageType] = useState<'error' | 'success'>('error');
|
|
||||||
|
|
||||||
const history = useHistory();
|
|
||||||
|
|
||||||
const showMessage = (msg: string, type: 'error' | 'success' = 'error') => {
|
|
||||||
setMessage(msg);
|
|
||||||
setMessageType(type);
|
|
||||||
setTimeout(() => setMessage(null), 4000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAuth = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!email || !password || (mode === 'signup' && !name)) {
|
|
||||||
showMessage('Please fill in all fields');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (mode === 'signup') {
|
|
||||||
const { data, error } = await supabase.auth.signUp({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
options: {
|
|
||||||
data: {
|
|
||||||
full_name: name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
if (data.session) {
|
|
||||||
history.push('/home');
|
|
||||||
} else {
|
|
||||||
history.push('/verify-email', { email });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const { data, 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, resent: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.session) {
|
|
||||||
history.push('/home');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
showMessage(err.message || 'An authentication error occurred');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonPage>
|
|
||||||
<IonContent
|
|
||||||
fullscreen
|
|
||||||
style={{
|
|
||||||
'--background':
|
|
||||||
'linear-gradient(160deg, rgba(199,210,254,0.52) 0%, #f0f4ff 58%, #f8fafc 100%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="auth-shell">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '12px',
|
|
||||||
marginTop: '20px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="icon-container"
|
|
||||||
style={{ width: '44px', height: '44px' }}
|
|
||||||
>
|
|
||||||
<IonIcon icon={storefrontOutline} style={{ fontSize: '22px' }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="bold" style={{ margin: '0', fontSize: '22px' }}>
|
|
||||||
ShopKeep
|
|
||||||
</h1>
|
|
||||||
<p className="secondary-text" style={{ margin: '0' }}>
|
|
||||||
Simple bookkeeping for you
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="auth-card">
|
|
||||||
<div className="pill-toggle">
|
|
||||||
<button
|
|
||||||
className={`pill-toggle-btn ${mode === 'signin' ? 'active' : ''}`}
|
|
||||||
onClick={() => setMode('signin')}
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`pill-toggle-btn ${mode === 'signup' ? 'active' : ''}`}
|
|
||||||
onClick={() => setMode('signup')}
|
|
||||||
>
|
|
||||||
Create account
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={handleAuth}
|
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
|
|
||||||
>
|
|
||||||
{mode === 'signup' && (
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<IonInput
|
|
||||||
label="Full Name"
|
|
||||||
labelPlacement="floating"
|
|
||||||
placeholder="Enter your name"
|
|
||||||
value={name}
|
|
||||||
onIonChange={(e) => setName(e.detail.value!)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<IonInput
|
|
||||||
label="Email"
|
|
||||||
labelPlacement="floating"
|
|
||||||
type="email"
|
|
||||||
placeholder="name@example.com"
|
|
||||||
value={email}
|
|
||||||
onIonChange={(e) => setEmail(e.detail.value!)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<IonInput
|
|
||||||
label="Password"
|
|
||||||
labelPlacement="floating"
|
|
||||||
type="password"
|
|
||||||
placeholder="Min. 6 characters"
|
|
||||||
value={password}
|
|
||||||
onIonChange={(e) => setPassword(e.detail.value!)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
messageType === 'error'
|
|
||||||
? 'var(--color-danger-soft)'
|
|
||||||
: 'var(--color-success-soft)',
|
|
||||||
color:
|
|
||||||
messageType === 'error'
|
|
||||||
? 'var(--ion-color-danger-shade)'
|
|
||||||
: 'var(--ion-color-success-shade)',
|
|
||||||
padding: '12px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IonButton
|
|
||||||
type="submit"
|
|
||||||
expand="block"
|
|
||||||
className="bold"
|
|
||||||
style={{
|
|
||||||
height: '52px',
|
|
||||||
'--border-radius': 'var(--radius-pill)',
|
|
||||||
marginTop: '8px',
|
|
||||||
}}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
<IonSpinner name="crescent" />
|
|
||||||
) : mode === 'signin' ? (
|
|
||||||
'Sign in'
|
|
||||||
) : (
|
|
||||||
'Create account'
|
|
||||||
)}
|
|
||||||
</IonButton>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div style={{ textAlign: 'center', marginTop: '8px' }}>
|
|
||||||
<IonText
|
|
||||||
color="primary"
|
|
||||||
className="semibold"
|
|
||||||
style={{ fontSize: '13px', cursor: 'pointer' }}
|
|
||||||
onClick={() => history.push('/forgot-password')}
|
|
||||||
>
|
|
||||||
Forgot password?
|
|
||||||
</IonText>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</IonContent>
|
|
||||||
</IonPage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AuthPage;
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
IonContent,
|
|
||||||
IonPage,
|
|
||||||
IonHeader,
|
|
||||||
IonToolbar,
|
|
||||||
IonTitle,
|
|
||||||
IonButtons,
|
|
||||||
IonButton,
|
|
||||||
IonIcon,
|
|
||||||
IonInput,
|
|
||||||
IonTextarea,
|
|
||||||
IonSpinner,
|
|
||||||
IonLabel,
|
|
||||||
IonSelect,
|
|
||||||
IonSelectOption,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import { chevronBackOutline } from 'ionicons/icons';
|
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
|
||||||
import { supabase } from '../supabase';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
import { getTodayISO } from '../utils/dates';
|
|
||||||
import MoneyInput from '../components/MoneyInput';
|
|
||||||
import { parseMoney } from '../utils/money';
|
|
||||||
|
|
||||||
interface Customer {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CreditFormPage: React.FC = () => {
|
|
||||||
const history = useHistory();
|
|
||||||
const location = useLocation<{ customerId?: string }>();
|
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
const [mode, setMode] = useState<'existing' | 'new'>('existing');
|
|
||||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
|
||||||
const [selectedCustomerId, setSelectedCustomerId] = useState<string>(
|
|
||||||
location.state?.customerId || ''
|
|
||||||
);
|
|
||||||
const [newName, setNewName] = useState('');
|
|
||||||
const [phone, setPhone] = useState('');
|
|
||||||
const [amount, setAmount] = useState('');
|
|
||||||
const [creditDate, setCreditDate] = useState(getTodayISO());
|
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [loadingCustomers, setLoadingCustomers] = useState(true);
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchCustomers = async () => {
|
|
||||||
if (!user) return;
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('credit_customers')
|
|
||||||
.select('id, name')
|
|
||||||
.eq('user_id', user.id)
|
|
||||||
.order('name');
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
setCustomers(data || []);
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
setMode('new');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching customers:', err);
|
|
||||||
} finally {
|
|
||||||
setLoadingCustomers(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchCustomers();
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const validate = () => {
|
|
||||||
const newErrors: Record<string, string> = {};
|
|
||||||
const parsedAmount = parseMoney(amount);
|
|
||||||
|
|
||||||
if (mode === 'existing' && !selectedCustomerId) {
|
|
||||||
newErrors.customer = 'Please select a customer';
|
|
||||||
}
|
|
||||||
if (mode === 'new' && !newName.trim()) {
|
|
||||||
newErrors.name = 'Customer name is required';
|
|
||||||
}
|
|
||||||
if (parsedAmount <= 0) {
|
|
||||||
newErrors.amount = 'Amount must be greater than 0';
|
|
||||||
}
|
|
||||||
if (!creditDate) {
|
|
||||||
newErrors.date = 'Date is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!validate() || !user) return;
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let customerId = selectedCustomerId;
|
|
||||||
|
|
||||||
if (mode === 'new') {
|
|
||||||
const { data: newCustomer, error: customerError } = await supabase
|
|
||||||
.from('credit_customers')
|
|
||||||
.insert([
|
|
||||||
{
|
|
||||||
user_id: user.id,
|
|
||||||
name: newName.trim(),
|
|
||||||
phone: phone.trim() || null,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (customerError) throw customerError;
|
|
||||||
customerId = newCustomer.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: creditError } = await supabase.from('credits').insert([
|
|
||||||
{
|
|
||||||
user_id: user.id,
|
|
||||||
customer_id: customerId,
|
|
||||||
amount: parseMoney(amount),
|
|
||||||
credit_date: creditDate,
|
|
||||||
description: description.trim() || null,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (creditError) throw creditError;
|
|
||||||
|
|
||||||
history.goBack();
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error saving credit:', err);
|
|
||||||
setMessage(err.message || 'Could not save credit');
|
|
||||||
setTimeout(() => setMessage(null), 4000);
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonPage>
|
|
||||||
<IonHeader className="ion-no-border">
|
|
||||||
<IonToolbar style={{ '--background': '#f0f4ff' }}>
|
|
||||||
<IonButtons slot="start">
|
|
||||||
<IonButton color="dark" onClick={() => history.goBack()}>
|
|
||||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
|
||||||
</IonButton>
|
|
||||||
</IonButtons>
|
|
||||||
<IonTitle className="bold">Add credit</IonTitle>
|
|
||||||
</IonToolbar>
|
|
||||||
</IonHeader>
|
|
||||||
|
|
||||||
<IonContent className="ion-padding" style={{ '--background': '#f0f4ff' }}>
|
|
||||||
<div className="form-card">
|
|
||||||
<div className="pill-toggle" style={{ marginBottom: '8px' }}>
|
|
||||||
<button
|
|
||||||
className={`pill-toggle-btn ${mode === 'existing' ? 'active' : ''}`}
|
|
||||||
onClick={() => setMode('existing')}
|
|
||||||
disabled={customers.length === 0}
|
|
||||||
>
|
|
||||||
Existing Customer
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`pill-toggle-btn ${mode === 'new' ? 'active' : ''}`}
|
|
||||||
onClick={() => setMode('new')}
|
|
||||||
>
|
|
||||||
New Customer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mode === 'existing' ? (
|
|
||||||
<div className="form-field">
|
|
||||||
<IonLabel
|
|
||||||
className="semibold"
|
|
||||||
style={{
|
|
||||||
marginBottom: '8px',
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Select Customer
|
|
||||||
</IonLabel>
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<IonSelect
|
|
||||||
interface="action-sheet"
|
|
||||||
placeholder="Choose a customer"
|
|
||||||
value={selectedCustomerId}
|
|
||||||
onIonChange={(e) => setSelectedCustomerId(e.detail.value)}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
>
|
|
||||||
{customers.map((c) => (
|
|
||||||
<IonSelectOption key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</IonSelectOption>
|
|
||||||
))}
|
|
||||||
</IonSelect>
|
|
||||||
</div>
|
|
||||||
{errors.customer && (
|
|
||||||
<IonLabel
|
|
||||||
color="danger"
|
|
||||||
style={{ fontSize: '12px', marginTop: '4px' }}
|
|
||||||
>
|
|
||||||
{errors.customer}
|
|
||||||
</IonLabel>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="form-field">
|
|
||||||
<IonLabel
|
|
||||||
className="semibold"
|
|
||||||
style={{
|
|
||||||
marginBottom: '8px',
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Customer Name
|
|
||||||
</IonLabel>
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<IonInput
|
|
||||||
placeholder="e.g. John Doe"
|
|
||||||
value={newName}
|
|
||||||
onIonChange={(e) => setNewName(e.detail.value!)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.name && (
|
|
||||||
<IonLabel
|
|
||||||
color="danger"
|
|
||||||
style={{ fontSize: '12px', marginTop: '4px' }}
|
|
||||||
>
|
|
||||||
{errors.name}
|
|
||||||
</IonLabel>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="form-field">
|
|
||||||
<IonLabel
|
|
||||||
className="semibold"
|
|
||||||
style={{
|
|
||||||
marginBottom: '8px',
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Phone (optional)
|
|
||||||
</IonLabel>
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<IonInput
|
|
||||||
type="tel"
|
|
||||||
placeholder="e.g. +1 234 567 890"
|
|
||||||
value={phone}
|
|
||||||
onIonChange={(e) => setPhone(e.detail.value!)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MoneyInput
|
|
||||||
label="Credit amount"
|
|
||||||
value={amount}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
setAmount(val);
|
|
||||||
if (errors.amount) setErrors({ ...errors, amount: '' });
|
|
||||||
}}
|
|
||||||
error={errors.amount}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="form-field">
|
|
||||||
<IonLabel
|
|
||||||
className="semibold"
|
|
||||||
style={{
|
|
||||||
marginBottom: '8px',
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Credit date
|
|
||||||
</IonLabel>
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<IonInput
|
|
||||||
type="date"
|
|
||||||
value={creditDate}
|
|
||||||
onIonChange={(e) => setCreditDate(e.detail.value!)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.date && (
|
|
||||||
<IonLabel
|
|
||||||
color="danger"
|
|
||||||
style={{ fontSize: '12px', marginTop: '4px' }}
|
|
||||||
>
|
|
||||||
{errors.date}
|
|
||||||
</IonLabel>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-field">
|
|
||||||
<IonLabel
|
|
||||||
className="semibold"
|
|
||||||
style={{
|
|
||||||
marginBottom: '8px',
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Description (optional)
|
|
||||||
</IonLabel>
|
|
||||||
<div
|
|
||||||
className="input-wrapper"
|
|
||||||
style={{ minHeight: '96px', padding: '8px 16px' }}
|
|
||||||
>
|
|
||||||
<IonTextarea
|
|
||||||
placeholder="e.g. 2 bags of rice, sugar"
|
|
||||||
value={description}
|
|
||||||
onIonChange={(e) => setDescription(e.detail.value!)}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'var(--color-danger-soft)',
|
|
||||||
color: 'var(--ion-color-danger-shade)',
|
|
||||||
padding: '12px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IonButton
|
|
||||||
expand="block"
|
|
||||||
className="bold"
|
|
||||||
style={{
|
|
||||||
height: '52px',
|
|
||||||
'--border-radius': 'var(--radius-pill)',
|
|
||||||
marginTop: '16px',
|
|
||||||
}}
|
|
||||||
disabled={submitting || (loadingCustomers && mode === 'existing')}
|
|
||||||
onClick={handleSave}
|
|
||||||
>
|
|
||||||
{submitting ? <IonSpinner name="crescent" /> : 'Save credit'}
|
|
||||||
</IonButton>
|
|
||||||
</div>
|
|
||||||
</IonContent>
|
|
||||||
</IonPage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreditFormPage;
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
import React, { useState, useCallback, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
IonContent,
|
|
||||||
IonPage,
|
|
||||||
IonHeader,
|
|
||||||
IonToolbar,
|
|
||||||
IonTitle,
|
|
||||||
IonButtons,
|
|
||||||
IonButton,
|
|
||||||
IonIcon,
|
|
||||||
IonRefresher,
|
|
||||||
IonRefresherContent,
|
|
||||||
IonAlert,
|
|
||||||
useIonViewWillEnter,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import {
|
|
||||||
chevronBackOutline,
|
|
||||||
trashOutline,
|
|
||||||
receiptOutline,
|
|
||||||
personAddOutline,
|
|
||||||
cashOutline,
|
|
||||||
} from 'ionicons/icons';
|
|
||||||
import { useHistory, useParams } from 'react-router-dom';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
import { supabase } from '../supabase';
|
|
||||||
import ActivityRow, { ActivityItem } from '../components/ActivityRow';
|
|
||||||
import EmptyState from '../components/EmptyState';
|
|
||||||
import { formatMoney } from '../utils/money';
|
|
||||||
|
|
||||||
const CustomerDetailPage: React.FC = () => {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const { user, profile } = useAuth();
|
|
||||||
const history = useHistory();
|
|
||||||
const currency = profile?.currency || 'USD';
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [customer, setCustomer] = useState<any>(null);
|
|
||||||
const [activity, setActivity] = useState<ActivityItem[]>([]);
|
|
||||||
const [balance, setBalance] = useState(0);
|
|
||||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
if (!user || !id) return;
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [
|
|
||||||
{ data: customerData },
|
|
||||||
{ data: creditsData },
|
|
||||||
{ data: repaymentsData },
|
|
||||||
] = await Promise.all([
|
|
||||||
supabase.from('credit_customers').select('*').eq('id', id).single(),
|
|
||||||
supabase
|
|
||||||
.from('credits')
|
|
||||||
.select('*')
|
|
||||||
.eq('customer_id', id)
|
|
||||||
.order('credit_date', { ascending: false }),
|
|
||||||
supabase
|
|
||||||
.from('repayments')
|
|
||||||
.select('*')
|
|
||||||
.eq('customer_id', id)
|
|
||||||
.order('repayment_date', { ascending: false }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (customerData) {
|
|
||||||
setCustomer(customerData);
|
|
||||||
|
|
||||||
const totalCredits = (creditsData || []).reduce(
|
|
||||||
(sum, c) => sum + Number(c.amount),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const totalRepayments = (repaymentsData || []).reduce(
|
|
||||||
(sum, r) => sum + Number(r.amount),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
setBalance(Math.max(0, totalCredits - totalRepayments));
|
|
||||||
|
|
||||||
const combinedActivity: ActivityItem[] = [
|
|
||||||
...(creditsData || []).map((c) => ({
|
|
||||||
id: c.id,
|
|
||||||
type: 'credit' as const,
|
|
||||||
amount: Number(c.amount),
|
|
||||||
date: c.credit_date,
|
|
||||||
note: c.description,
|
|
||||||
})),
|
|
||||||
...(repaymentsData || []).map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
type: 'repayment' as const,
|
|
||||||
amount: Number(r.amount),
|
|
||||||
date: r.repayment_date,
|
|
||||||
note: r.note,
|
|
||||||
})),
|
|
||||||
].sort((a, b) => b.date.localeCompare(a.date));
|
|
||||||
|
|
||||||
setActivity(combinedActivity);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching customer detail:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [user, id]);
|
|
||||||
|
|
||||||
useIonViewWillEnter(() => {
|
|
||||||
fetchData();
|
|
||||||
}, [fetchData]);
|
|
||||||
|
|
||||||
const handleRefresh = async (e: CustomEvent) => {
|
|
||||||
await fetchData();
|
|
||||||
e.detail.complete();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
try {
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('credit_customers')
|
|
||||||
.delete()
|
|
||||||
.eq('id', id);
|
|
||||||
if (error) throw error;
|
|
||||||
history.push('/customers');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error deleting customer:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getInitials = (name: string) => {
|
|
||||||
return name
|
|
||||||
.split(' ')
|
|
||||||
.map((n) => n[0])
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
.slice(0, 2);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!loading && !customer) {
|
|
||||||
return (
|
|
||||||
<IonPage>
|
|
||||||
<IonContent
|
|
||||||
className="ion-padding"
|
|
||||||
style={{ '--background': '#f0f4ff' }}
|
|
||||||
>
|
|
||||||
<EmptyState
|
|
||||||
icon="alert-circle-outline"
|
|
||||||
title="Customer not found"
|
|
||||||
actionText="Go Back"
|
|
||||||
onAction={() => history.push('/customers')}
|
|
||||||
/>
|
|
||||||
</IonContent>
|
|
||||||
</IonPage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonPage>
|
|
||||||
<IonHeader className="ion-no-border">
|
|
||||||
<IonToolbar style={{ '--background': '#f0f4ff' }}>
|
|
||||||
<IonButtons slot="start">
|
|
||||||
<IonButton color="dark" onClick={() => history.push('/customers')}>
|
|
||||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
|
||||||
</IonButton>
|
|
||||||
</IonButtons>
|
|
||||||
<IonTitle className="bold">Customer</IonTitle>
|
|
||||||
<IonButtons slot="end">
|
|
||||||
<IonButton color="danger" onClick={() => setShowDeleteAlert(true)}>
|
|
||||||
<IonIcon icon={trashOutline} slot="icon-only" />
|
|
||||||
</IonButton>
|
|
||||||
</IonButtons>
|
|
||||||
</IonToolbar>
|
|
||||||
</IonHeader>
|
|
||||||
|
|
||||||
<IonContent fullscreen style={{ '--background': '#f0f4ff' }}>
|
|
||||||
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
|
|
||||||
<IonRefresherContent />
|
|
||||||
</IonRefresher>
|
|
||||||
|
|
||||||
<div className="page-padding" style={{ paddingTop: '16px' }}>
|
|
||||||
<div className="widget-card" style={{ padding: '24px 20px' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: '20px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="icon-container bold"
|
|
||||||
style={{
|
|
||||||
width: '56px',
|
|
||||||
height: '56px',
|
|
||||||
fontSize: '18px',
|
|
||||||
marginRight: '16px',
|
|
||||||
borderRadius: '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{customer ? getInitials(customer.name) : ''}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="bold" style={{ margin: '0', fontSize: '20px' }}>
|
|
||||||
{customer?.name}
|
|
||||||
</h2>
|
|
||||||
{customer?.phone && (
|
|
||||||
<p className="secondary-text" style={{ margin: '2px 0 0 0' }}>
|
|
||||||
{customer.phone}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '24px' }}>
|
|
||||||
<span className="tertiary-text">OUTSTANDING BALANCE</span>
|
|
||||||
<div
|
|
||||||
className="tabular"
|
|
||||||
style={{
|
|
||||||
fontSize: '34px',
|
|
||||||
fontWeight: '400',
|
|
||||||
color:
|
|
||||||
balance > 0
|
|
||||||
? 'var(--ion-color-danger)'
|
|
||||||
: 'var(--ion-color-success)',
|
|
||||||
marginTop: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatMoney(balance, currency)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
padding: '4px 12px',
|
|
||||||
borderRadius: '999px',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '600',
|
|
||||||
marginTop: '8px',
|
|
||||||
background:
|
|
||||||
balance > 0
|
|
||||||
? 'var(--color-danger-soft)'
|
|
||||||
: 'var(--color-success-soft)',
|
|
||||||
color:
|
|
||||||
balance > 0
|
|
||||||
? 'var(--ion-color-danger)'
|
|
||||||
: 'var(--ion-color-success)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{balance > 0 ? 'OWING' : 'CLEARED'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
|
||||||
<IonButton
|
|
||||||
fill="clear"
|
|
||||||
className="bold"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: '14px',
|
|
||||||
background: 'var(--color-surface-secondary)',
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
'--border-radius': 'var(--radius-inner)',
|
|
||||||
height: '44px',
|
|
||||||
}}
|
|
||||||
onClick={() => history.push('/credit/new', { customerId: id })}
|
|
||||||
>
|
|
||||||
<IonIcon icon={personAddOutline} slot="start" />
|
|
||||||
Credit
|
|
||||||
</IonButton>
|
|
||||||
<IonButton
|
|
||||||
fill="solid"
|
|
||||||
className="bold"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: '14px',
|
|
||||||
'--border-radius': 'var(--radius-inner)',
|
|
||||||
height: '44px',
|
|
||||||
}}
|
|
||||||
onClick={() =>
|
|
||||||
history.push('/repayment/new', { customerId: id })
|
|
||||||
}
|
|
||||||
disabled={balance === 0}
|
|
||||||
>
|
|
||||||
<IonIcon icon={cashOutline} slot="start" />
|
|
||||||
Repay
|
|
||||||
</IonButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="section-header">
|
|
||||||
<h2>Activity</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="page-padding" style={{ paddingBottom: '40px' }}>
|
|
||||||
{loading ? (
|
|
||||||
<div className="widget-card" style={{ gap: '16px' }}>
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '40px',
|
|
||||||
height: '40px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
background: 'var(--color-surface-secondary)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '60%',
|
|
||||||
height: '12px',
|
|
||||||
background: 'var(--color-surface-secondary)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
marginBottom: '6px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '40%',
|
|
||||||
height: '10px',
|
|
||||||
background: 'var(--color-surface-secondary)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : activity.length > 0 ? (
|
|
||||||
<div className="widget-card" style={{ padding: '8px 16px' }}>
|
|
||||||
{activity.map((item) => (
|
|
||||||
<ActivityRow
|
|
||||||
key={`${item.type}-${item.id}`}
|
|
||||||
item={item}
|
|
||||||
showDate
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon={receiptOutline}
|
|
||||||
title="No activity yet"
|
|
||||||
description="Record the first credit or repayment for this customer."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<IonAlert
|
|
||||||
isOpen={showDeleteAlert}
|
|
||||||
onDidDismiss={() => setShowDeleteAlert(false)}
|
|
||||||
header="Delete Customer"
|
|
||||||
message="Are you sure you want to delete this customer? All their credit and repayment history will be lost."
|
|
||||||
buttons={[
|
|
||||||
{ text: 'Cancel', role: 'cancel' },
|
|
||||||
{ text: 'Delete', role: 'destructive', handler: handleDelete },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</IonContent>
|
|
||||||
</IonPage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CustomerDetailPage;
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
import React, { useState, useCallback, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
IonContent,
|
|
||||||
IonPage,
|
|
||||||
IonIcon,
|
|
||||||
IonSegmentButton,
|
|
||||||
IonSearchbar,
|
|
||||||
IonRefresher,
|
|
||||||
IonRefresherContent,
|
|
||||||
useIonViewWillEnter,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import { personAddOutline, peopleOutline } from 'ionicons/icons';
|
|
||||||
import { useHistory } from 'react-router-dom';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
import { supabase } from '../supabase';
|
|
||||||
import MetricCard from '../components/MetricCard';
|
|
||||||
import CustomerRow from '../components/CustomerRow';
|
|
||||||
import EmptyState from '../components/EmptyState';
|
|
||||||
|
|
||||||
interface CustomerWithBalance {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
phone: string | null;
|
|
||||||
balance: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomersPage: React.FC = () => {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const history = useHistory();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [customers, setCustomers] = useState<CustomerWithBalance[]>([]);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
if (!user) return;
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [
|
|
||||||
{ data: customersData },
|
|
||||||
{ data: creditsData },
|
|
||||||
{ data: repaymentsData },
|
|
||||||
] = await Promise.all([
|
|
||||||
supabase
|
|
||||||
.from('credit_customers')
|
|
||||||
.select('*')
|
|
||||||
.eq('user_id', user.id)
|
|
||||||
.order('name'),
|
|
||||||
supabase
|
|
||||||
.from('credits')
|
|
||||||
.select('customer_id, amount')
|
|
||||||
.eq('user_id', user.id),
|
|
||||||
supabase
|
|
||||||
.from('repayments')
|
|
||||||
.select('customer_id, amount')
|
|
||||||
.eq('user_id', user.id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const customerBalances: CustomerWithBalance[] = (customersData || []).map(
|
|
||||||
(c) => {
|
|
||||||
const totalCredits = (creditsData || [])
|
|
||||||
.filter((cr) => cr.customer_id === c.id)
|
|
||||||
.reduce((sum, cr) => sum + Number(cr.amount), 0);
|
|
||||||
const totalRepayments = (repaymentsData || [])
|
|
||||||
.filter((r) => r.customer_id === c.id)
|
|
||||||
.reduce((sum, r) => sum + Number(r.amount), 0);
|
|
||||||
return {
|
|
||||||
id: c.id,
|
|
||||||
name: c.name,
|
|
||||||
phone: c.phone,
|
|
||||||
balance: Math.max(0, totalCredits - totalRepayments),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sort: outstanding balances first, then by name
|
|
||||||
setCustomers(
|
|
||||||
customerBalances.sort((a, b) => {
|
|
||||||
if (a.balance > 0 && b.balance === 0) return -1;
|
|
||||||
if (a.balance === 0 && b.balance > 0) return 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching customers:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
useIonViewWillEnter(() => {
|
|
||||||
fetchData();
|
|
||||||
}, [fetchData]);
|
|
||||||
|
|
||||||
const handleRefresh = async (e: CustomEvent) => {
|
|
||||||
await fetchData();
|
|
||||||
e.detail.complete();
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredCustomers = useMemo(() => {
|
|
||||||
const query = searchQuery.toLowerCase().trim();
|
|
||||||
if (!query) return customers;
|
|
||||||
return customers.filter(
|
|
||||||
(c) =>
|
|
||||||
c.name.toLowerCase().includes(query) ||
|
|
||||||
(c.phone && c.phone.toLowerCase().includes(query)),
|
|
||||||
);
|
|
||||||
}, [customers, searchQuery]);
|
|
||||||
|
|
||||||
const totalOutstanding = useMemo(() => {
|
|
||||||
return customers.reduce((sum, c) => sum + c.balance, 0);
|
|
||||||
}, [customers]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonPage>
|
|
||||||
<IonContent
|
|
||||||
fullscreen
|
|
||||||
style={{
|
|
||||||
'--background':
|
|
||||||
'linear-gradient(160deg, rgba(199,210,254,0.35) 0%, #f0f4ff 58%, #f8fafc 100%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
|
|
||||||
<IonRefresherContent />
|
|
||||||
</IonRefresher>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 16px',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h1 className="bold" style={{ margin: '0', fontSize: '20px' }}>
|
|
||||||
Credit customers
|
|
||||||
</h1>
|
|
||||||
<p className="secondary-text" style={{ margin: '0' }}>
|
|
||||||
Track who owes your shop
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="icon-container"
|
|
||||||
style={{
|
|
||||||
borderRadius: '12px',
|
|
||||||
background: '#ffffff',
|
|
||||||
color: 'var(--ion-color-primary)',
|
|
||||||
}}
|
|
||||||
onClick={() => history.push('/credit/new')}
|
|
||||||
>
|
|
||||||
<IonIcon icon={personAddOutline} style={{ fontSize: '22px' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="page-padding">
|
|
||||||
<IonSearchbar
|
|
||||||
value={searchQuery}
|
|
||||||
onIonInput={(e) => setSearchQuery(e.detail.value!)}
|
|
||||||
placeholder="Search customers"
|
|
||||||
className="custom-searchbar"
|
|
||||||
style={{
|
|
||||||
'--background': 'var(--color-surface-secondary)',
|
|
||||||
'--border-radius': '24px',
|
|
||||||
'--box-shadow': 'none',
|
|
||||||
padding: '0',
|
|
||||||
height: '44px',
|
|
||||||
marginBottom: '16px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MetricCard
|
|
||||||
label="Total Outstanding"
|
|
||||||
value={totalOutstanding}
|
|
||||||
fullWidth
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="section-header">
|
|
||||||
<h2>
|
|
||||||
Customers{' '}
|
|
||||||
{!loading && (
|
|
||||||
<span className="tertiary-text" style={{ marginLeft: '4px' }}>
|
|
||||||
({customers.length})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="page-padding" style={{ paddingBottom: '120px' }}>
|
|
||||||
{loading ? (
|
|
||||||
<div className="widget-card" style={{ gap: '16px' }}>
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '44px',
|
|
||||||
height: '44px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
background: 'var(--color-surface-secondary)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '50%',
|
|
||||||
height: '14px',
|
|
||||||
background: 'var(--color-surface-secondary)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
marginBottom: '6px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '30%',
|
|
||||||
height: '10px',
|
|
||||||
background: 'var(--color-surface-secondary)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : customers.length > 0 ? (
|
|
||||||
<>
|
|
||||||
{filteredCustomers.length > 0 ? (
|
|
||||||
<div className="widget-card" style={{ padding: '8px 16px' }}>
|
|
||||||
{filteredCustomers.map((c) => (
|
|
||||||
<CustomerRow
|
|
||||||
key={c.id}
|
|
||||||
id={c.id}
|
|
||||||
name={c.name}
|
|
||||||
phone={c.phone || undefined}
|
|
||||||
balance={c.balance}
|
|
||||||
onClick={() => history.push(`/customers/${c.id}`)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
|
|
||||||
<p className="secondary-text">
|
|
||||||
No customers matching "{searchQuery}"
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon={peopleOutline}
|
|
||||||
title="No credit customers yet"
|
|
||||||
description="Add a credit sale to start tracking balances."
|
|
||||||
actionText="Add Credit"
|
|
||||||
onAction={() => history.push('/credit/new')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</IonContent>
|
|
||||||
</IonPage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CustomersPage;
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
IonContent,
|
|
||||||
IonPage,
|
|
||||||
IonInput,
|
|
||||||
IonButton,
|
|
||||||
IonSpinner,
|
|
||||||
IonIcon,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import { chevronBackOutline } from 'ionicons/icons';
|
|
||||||
import { useHistory } from 'react-router-dom';
|
|
||||||
import { supabase } from '../supabase';
|
|
||||||
|
|
||||||
const ForgotPasswordPage: React.FC = () => {
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
const [messageType, setMessageType] = useState<'error' | 'success'>('error');
|
|
||||||
|
|
||||||
const history = useHistory();
|
|
||||||
|
|
||||||
const showMessage = (msg: string, type: 'error' | 'success' = 'error') => {
|
|
||||||
setMessage(msg);
|
|
||||||
setMessageType(type);
|
|
||||||
setTimeout(() => setMessage(null), 4000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!email) {
|
|
||||||
showMessage('Please enter your email');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { error } = await supabase.auth.resetPasswordForEmail(email);
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
history.push('/verify-email', { email, type: 'recovery' });
|
|
||||||
} catch (err: any) {
|
|
||||||
showMessage(err.message || 'An error occurred');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonPage>
|
|
||||||
<IonContent
|
|
||||||
fullscreen
|
|
||||||
style={{
|
|
||||||
'--background':
|
|
||||||
'linear-gradient(160deg, rgba(199,210,254,0.52) 0%, #f0f4ff 58%, #f8fafc 100%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="auth-shell">
|
|
||||||
<IonButton
|
|
||||||
fill="clear"
|
|
||||||
color="dark"
|
|
||||||
onClick={() => history.push('/auth')}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 'calc(var(--ion-safe-area-top, 0px) + 8px)',
|
|
||||||
left: '8px',
|
|
||||||
'--padding-start': '8px',
|
|
||||||
'--padding-end': '8px',
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#ffffff',
|
|
||||||
width: '44px',
|
|
||||||
height: '44px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon icon={chevronBackOutline} />
|
|
||||||
</div>
|
|
||||||
</IonButton>
|
|
||||||
|
|
||||||
<div style={{ marginTop: '80px' }}>
|
|
||||||
<h1
|
|
||||||
className="bold"
|
|
||||||
style={{ fontSize: '24px', margin: '0 0 8px 0' }}
|
|
||||||
>
|
|
||||||
Reset password
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
className="secondary-text"
|
|
||||||
style={{ margin: '0', fontSize: '14px' }}
|
|
||||||
>
|
|
||||||
Enter your email and we'll send a 6-digit recovery code.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="auth-card">
|
|
||||||
<form
|
|
||||||
onSubmit={handleReset}
|
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
|
|
||||||
>
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<IonInput
|
|
||||||
label="Email"
|
|
||||||
labelPlacement="floating"
|
|
||||||
type="email"
|
|
||||||
placeholder="name@example.com"
|
|
||||||
value={email}
|
|
||||||
onIonChange={(e) => setEmail(e.detail.value!)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
messageType === 'error'
|
|
||||||
? 'var(--color-danger-soft)'
|
|
||||||
: 'var(--color-success-soft)',
|
|
||||||
color:
|
|
||||||
messageType === 'error'
|
|
||||||
? 'var(--ion-color-danger-shade)'
|
|
||||||
: 'var(--ion-color-success-shade)',
|
|
||||||
padding: '12px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
fontSize: '13px',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IonButton
|
|
||||||
type="submit"
|
|
||||||
expand="block"
|
|
||||||
className="bold"
|
|
||||||
style={{
|
|
||||||
height: '52px',
|
|
||||||
'--border-radius': 'var(--radius-pill)',
|
|
||||||
marginTop: '8px',
|
|
||||||
}}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
{submitting ? <IonSpinner name="crescent" /> : 'Send code'}
|
|
||||||
</IonButton>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</IonContent>
|
|
||||||
</IonPage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ForgotPasswordPage;
|
|
||||||
@@ -0,0 +1,623 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IonAlert,
|
||||||
|
IonButton,
|
||||||
|
IonButtons,
|
||||||
|
IonContent,
|
||||||
|
IonHeader,
|
||||||
|
IonIcon,
|
||||||
|
IonPage,
|
||||||
|
IonSkeletonText,
|
||||||
|
IonTitle,
|
||||||
|
IonToolbar,
|
||||||
|
useIonViewWillEnter,
|
||||||
|
} from '@ionic/react';
|
||||||
|
import { IonBackButton } from '@ionic/react';
|
||||||
|
import {
|
||||||
|
checkmarkOutline,
|
||||||
|
createOutline,
|
||||||
|
ellipseOutline,
|
||||||
|
flameOutline,
|
||||||
|
sparklesOutline,
|
||||||
|
trashOutline,
|
||||||
|
trendingUpOutline,
|
||||||
|
} from 'ionicons/icons';
|
||||||
|
import { Redirect, useHistory, useParams } from 'react-router-dom';
|
||||||
|
import type { Tables } from '../database.types';
|
||||||
|
import { supabase } from '../supabase';
|
||||||
|
import HabitFormModal from '../components/HabitFormModal';
|
||||||
|
import {
|
||||||
|
Style,
|
||||||
|
setStatusBarBackground,
|
||||||
|
setStatusBarStyle,
|
||||||
|
} from '../utils/statusBar';
|
||||||
|
|
||||||
|
interface HabitWithCompletions extends Tables<'habits'> {
|
||||||
|
completions: Tables<'habit_completions'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const calculateStreak = (dates: string[]) => {
|
||||||
|
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = new Date();
|
||||||
|
|
||||||
|
if (!unique.includes(formatDate(cursor))) {
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (unique.includes(formatDate(cursor))) {
|
||||||
|
streak += 1;
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return streak;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HabitDetail: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
|
const [habit, setHabit] = useState<HabitWithCompletions | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||||
|
|
||||||
|
const loadHabit = useCallback(async () => {
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
error: sessionError,
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (sessionError) {
|
||||||
|
setError(sessionError.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
setUserId(null);
|
||||||
|
setSessionChecked(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserId(session.user.id);
|
||||||
|
setSessionChecked(true);
|
||||||
|
|
||||||
|
const { data, error: habitError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.select('*, completions:habit_completions(*)')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (habitError) {
|
||||||
|
if (habitError.code === 'PGRST116') {
|
||||||
|
setHabit(null);
|
||||||
|
} else {
|
||||||
|
setError(habitError.message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHabit(data as HabitWithCompletions);
|
||||||
|
setLoading(false);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadHabit();
|
||||||
|
}, [loadHabit]);
|
||||||
|
|
||||||
|
const completionDates = useMemo(
|
||||||
|
() => habit?.completions.map((c) => c.completed_on) ?? [],
|
||||||
|
[habit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const today = formatDate(new Date());
|
||||||
|
const doneToday = completionDates.includes(today);
|
||||||
|
|
||||||
|
const currentStreak = useMemo(
|
||||||
|
() => calculateStreak(completionDates),
|
||||||
|
[completionDates],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalCompletions = completionDates.length;
|
||||||
|
|
||||||
|
// Calculate longest streak
|
||||||
|
const longestStreak = useMemo(() => {
|
||||||
|
const unique = Array.from(new Set(completionDates)).sort((a, b) =>
|
||||||
|
a.localeCompare(b),
|
||||||
|
);
|
||||||
|
if (unique.length === 0) return 0;
|
||||||
|
let maxStreak = 1;
|
||||||
|
let current = 1;
|
||||||
|
for (let i = 1; i < unique.length; i++) {
|
||||||
|
const prev = new Date(unique[i - 1]);
|
||||||
|
const curr = new Date(unique[i]);
|
||||||
|
const diffDays =
|
||||||
|
(curr.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
if (diffDays === 1) {
|
||||||
|
current += 1;
|
||||||
|
maxStreak = Math.max(maxStreak, current);
|
||||||
|
} else {
|
||||||
|
current = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxStreak;
|
||||||
|
}, [completionDates]);
|
||||||
|
|
||||||
|
// Days since habit creation (or first completion, whichever is earlier)
|
||||||
|
const daysSinceStart = useMemo(() => {
|
||||||
|
const startDate = habit
|
||||||
|
? new Date(
|
||||||
|
Math.min(
|
||||||
|
new Date(habit.created_at).getTime(),
|
||||||
|
...completionDates.map((d) => new Date(d).getTime()),
|
||||||
|
Date.now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: new Date();
|
||||||
|
const diff = Math.ceil(
|
||||||
|
(Date.now() - startDate.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
);
|
||||||
|
return Math.max(diff, 1);
|
||||||
|
}, [habit, completionDates]);
|
||||||
|
|
||||||
|
const consistencyRate = Math.round((totalCompletions / daysSinceStart) * 100);
|
||||||
|
|
||||||
|
// 30-day heatmap data
|
||||||
|
const heatmapDays = useMemo(() => {
|
||||||
|
return Array.from({ length: 30 }, (_, i) => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - (29 - i));
|
||||||
|
const key = formatDate(date);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
completed: completionDates.includes(key),
|
||||||
|
isToday: key === today,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [completionDates, today]);
|
||||||
|
|
||||||
|
const toggleToday = async () => {
|
||||||
|
if (!userId || !habit) return;
|
||||||
|
setBusy(true);
|
||||||
|
|
||||||
|
if (doneToday) {
|
||||||
|
const existing = habit.completions.find((c) => c.completed_on === today);
|
||||||
|
if (existing) {
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from('habit_completions')
|
||||||
|
.delete()
|
||||||
|
.eq('id', existing.id);
|
||||||
|
if (deleteError) setError(deleteError.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { error: insertError } = await supabase
|
||||||
|
.from('habit_completions')
|
||||||
|
.insert({
|
||||||
|
habit_id: habit.id,
|
||||||
|
user_id: userId,
|
||||||
|
completed_on: today,
|
||||||
|
});
|
||||||
|
if (insertError) setError(insertError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadHabit();
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHabit = async (values: {
|
||||||
|
name: string;
|
||||||
|
targetDescription: string;
|
||||||
|
color: string;
|
||||||
|
}) => {
|
||||||
|
if (!userId || !habit) throw new Error('Habit not found.');
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.update({
|
||||||
|
name: values.name,
|
||||||
|
target_description: values.targetDescription || null,
|
||||||
|
color: values.color,
|
||||||
|
})
|
||||||
|
.eq('id', habit.id);
|
||||||
|
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
await loadHabit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteHabit = async () => {
|
||||||
|
if (!habit) return;
|
||||||
|
setBusy(true);
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.delete()
|
||||||
|
.eq('id', habit.id);
|
||||||
|
if (deleteError) {
|
||||||
|
setError(deleteError.message);
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
history.push('/home');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sessionChecked && !userId) {
|
||||||
|
return <Redirect to="/auth" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader className="ion-no-border">
|
||||||
|
<IonToolbar>
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonBackButton defaultHref="/home" />
|
||||||
|
</IonButtons>
|
||||||
|
<IonTitle>{habit?.name ?? 'Habit'}</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
{habit && (
|
||||||
|
<>
|
||||||
|
<IonButton fill="clear" onClick={() => setShowEditModal(true)}>
|
||||||
|
<IonIcon icon={createOutline} />
|
||||||
|
</IonButton>
|
||||||
|
<IonButton
|
||||||
|
fill="clear"
|
||||||
|
color="danger"
|
||||||
|
onClick={() => setShowDeleteAlert(true)}
|
||||||
|
>
|
||||||
|
<IonIcon icon={trashOutline} />
|
||||||
|
</IonButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
|
||||||
|
<IonContent fullscreen>
|
||||||
|
<div className="dashboard-shell ion-padding">
|
||||||
|
{/* Loading state */}
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
style={{ width: '60%', height: '24px', borderRadius: '8px' }}
|
||||||
|
/>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '120px',
|
||||||
|
borderRadius: '24px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '160px',
|
||||||
|
borderRadius: '24px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100px',
|
||||||
|
borderRadius: '24px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : error ? (
|
||||||
|
/* Error state */
|
||||||
|
<div className="inline-message error-card">
|
||||||
|
<h3>Something went wrong</h3>
|
||||||
|
<p>{error}</p>
|
||||||
|
<IonButton onClick={loadHabit}>Try Again</IonButton>
|
||||||
|
</div>
|
||||||
|
) : !habit ? (
|
||||||
|
/* Not found state */
|
||||||
|
<div className="inline-message">
|
||||||
|
<h3>Habit not found</h3>
|
||||||
|
<p>
|
||||||
|
This habit may have been deleted or you don't have access
|
||||||
|
to it.
|
||||||
|
</p>
|
||||||
|
<IonButton routerLink="/home">Back to habits</IonButton>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Habit info card */}
|
||||||
|
<section className="hero-card compact">
|
||||||
|
<div className="habit-copy-topline">
|
||||||
|
<span
|
||||||
|
className="habit-color-pill"
|
||||||
|
style={{ background: habit.color }}
|
||||||
|
/>
|
||||||
|
<span className="eyebrow">Your routine</span>
|
||||||
|
</div>
|
||||||
|
<h1>{habit.name}</h1>
|
||||||
|
<p>
|
||||||
|
{habit.target_description ||
|
||||||
|
'Show up today and keep the chain alive.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Today toggle */}
|
||||||
|
<div style={{ marginTop: '18px' }}>
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
fill={doneToday ? 'solid' : 'outline'}
|
||||||
|
onClick={toggleToday}
|
||||||
|
disabled={busy}
|
||||||
|
style={
|
||||||
|
doneToday
|
||||||
|
? ({
|
||||||
|
'--background': habit.color,
|
||||||
|
'--background-hover': habit.color,
|
||||||
|
'--background-activated': habit.color,
|
||||||
|
'--border-radius': '18px',
|
||||||
|
minHeight: '50px',
|
||||||
|
} as any)
|
||||||
|
: ({
|
||||||
|
'--border-color': habit.color,
|
||||||
|
'--color': habit.color,
|
||||||
|
'--border-radius': '18px',
|
||||||
|
minHeight: '50px',
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IonIcon
|
||||||
|
icon={doneToday ? checkmarkOutline : ellipseOutline}
|
||||||
|
slot="start"
|
||||||
|
/>
|
||||||
|
{doneToday ? 'Completed today' : "Mark today's habit done"}
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* All-time stats */}
|
||||||
|
<section>
|
||||||
|
<p className="home-section-label">All-time stats</p>
|
||||||
|
<div className="stack-list">
|
||||||
|
{/* Stats row */}
|
||||||
|
<div
|
||||||
|
className="hero-card compact"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr 1fr',
|
||||||
|
gap: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '1.8rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: habit.color,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{totalCompletions}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
marginTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
total done
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '1.8rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: habit.color,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{longestStreak}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
marginTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
best streak
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '1.8rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: habit.color,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consistencyRate}%
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
marginTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
consistency
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current streak highlight */}
|
||||||
|
<div
|
||||||
|
className="hero-card compact"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: '16px',
|
||||||
|
background: `${habit.color}18`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IonIcon
|
||||||
|
icon={flameOutline}
|
||||||
|
style={{ color: habit.color, fontSize: '1.3rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--ion-color-dark)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentStreak} day{currentStreak === 1 ? '' : 's'} and
|
||||||
|
counting
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '4px 0 0',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
fontSize: '0.84rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentStreak > 0
|
||||||
|
? `You've shown up every day since ${(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - currentStreak + 1);
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
})()}.`
|
||||||
|
: "Start today and build a streak you're proud of."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 30-day heatmap */}
|
||||||
|
<section>
|
||||||
|
<p className="home-section-label">Last 30 days</p>
|
||||||
|
<div className="hero-card compact">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||||
|
gap: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{heatmapDays.map((day) => (
|
||||||
|
<div
|
||||||
|
key={day.key}
|
||||||
|
title={
|
||||||
|
day.isToday
|
||||||
|
? 'Today'
|
||||||
|
: new Date(day.key).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: day.completed
|
||||||
|
? habit.color
|
||||||
|
: `${habit.color}14`,
|
||||||
|
border: day.isToday
|
||||||
|
? `2px solid ${habit.color}`
|
||||||
|
: '2px solid transparent',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: '12px',
|
||||||
|
fontSize: '0.74rem',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>30 days ago</span>
|
||||||
|
<span>
|
||||||
|
<IonIcon
|
||||||
|
icon={trendingUpOutline}
|
||||||
|
style={{ verticalAlign: 'middle', marginRight: '4px' }}
|
||||||
|
/>
|
||||||
|
{heatmapDays.filter((d) => d.completed).length} of 30 days
|
||||||
|
</span>
|
||||||
|
<span>Today</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Edit modal */}
|
||||||
|
{showEditModal && (
|
||||||
|
<HabitFormModal
|
||||||
|
isOpen={showEditModal}
|
||||||
|
onDismiss={() => setShowEditModal(false)}
|
||||||
|
onSubmit={updateHabit}
|
||||||
|
initialValues={{
|
||||||
|
name: habit.name,
|
||||||
|
targetDescription: habit.target_description ?? '',
|
||||||
|
color: habit.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<IonAlert
|
||||||
|
isOpen={showDeleteAlert}
|
||||||
|
onDidDismiss={() => setShowDeleteAlert(false)}
|
||||||
|
header="Delete habit?"
|
||||||
|
message={`Are you sure you want to delete "${habit?.name ?? ''}"? This will also remove all its completion history. This action cannot be undone.`}
|
||||||
|
buttons={[
|
||||||
|
{
|
||||||
|
text: 'Cancel',
|
||||||
|
role: 'cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
role: 'destructive',
|
||||||
|
handler: deleteHabit,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HabitDetail;
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
import React, { useState, useCallback, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
IonContent,
|
|
||||||
IonPage,
|
|
||||||
IonIcon,
|
|
||||||
IonRefresher,
|
|
||||||
IonRefresherContent,
|
|
||||||
IonSegment,
|
|
||||||
IonSegmentButton,
|
|
||||||
IonLabel,
|
|
||||||
IonActionSheet,
|
|
||||||
useIonViewWillEnter,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import {
|
|
||||||
addOutline,
|
|
||||||
receiptOutline,
|
|
||||||
personAddOutline,
|
|
||||||
cashOutline,
|
|
||||||
} from 'ionicons/icons';
|
|
||||||
import { useHistory } from 'react-router-dom';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
import { supabase } from '../supabase';
|
|
||||||
import ActivityRow, { ActivityItem } from '../components/ActivityRow';
|
|
||||||
import EmptyState from '../components/EmptyState';
|
|
||||||
import MetricCard from '../components/MetricCard';
|
|
||||||
import { groupItemsByDate, formatRelativeDate } from '../utils/dates';
|
|
||||||
|
|
||||||
const HistoryPage: React.FC = () => {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const history = useHistory();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [filter, setFilter] = useState<
|
|
||||||
'all' | 'sales' | 'credit' | 'repayments'
|
|
||||||
>('all');
|
|
||||||
const [items, setItems] = useState<ActivityItem[]>([]);
|
|
||||||
const [showQuickAdd, setShowQuickAdd] = useState(false);
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
if (!user) return;
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [
|
|
||||||
{ data: salesData },
|
|
||||||
{ data: creditsData },
|
|
||||||
{ data: repaymentsData },
|
|
||||||
] = await Promise.all([
|
|
||||||
supabase
|
|
||||||
.from('sales')
|
|
||||||
.select('*')
|
|
||||||
.eq('user_id', user.id)
|
|
||||||
.order('sale_date', { ascending: false }),
|
|
||||||
supabase
|
|
||||||
.from('credits')
|
|
||||||
.select('*, credit_customers(name)')
|
|
||||||
.eq('user_id', user.id)
|
|
||||||
.order('credit_date', { ascending: false }),
|
|
||||||
supabase
|
|
||||||
.from('repayments')
|
|
||||||
.select('*, credit_customers(name)')
|
|
||||||
.eq('user_id', user.id)
|
|
||||||
.order('repayment_date', { ascending: false }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const combined: ActivityItem[] = [
|
|
||||||
...(salesData || []).map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
type: 'sale' as const,
|
|
||||||
amount: Number(s.amount),
|
|
||||||
date: s.sale_date,
|
|
||||||
note: s.note,
|
|
||||||
})),
|
|
||||||
...(creditsData || []).map((c) => ({
|
|
||||||
id: c.id,
|
|
||||||
type: 'credit' as const,
|
|
||||||
amount: Number(c.amount),
|
|
||||||
date: c.credit_date,
|
|
||||||
customerName: (c.credit_customers as any)?.name,
|
|
||||||
note: c.description,
|
|
||||||
customerId: c.customer_id,
|
|
||||||
})),
|
|
||||||
...(repaymentsData || []).map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
type: 'repayment' as const,
|
|
||||||
amount: Number(r.amount),
|
|
||||||
date: r.repayment_date,
|
|
||||||
customerName: (r.credit_customers as any)?.name,
|
|
||||||
note: r.note,
|
|
||||||
customerId: r.customer_id,
|
|
||||||
})),
|
|
||||||
].sort((a, b) => b.date.localeCompare(a.date));
|
|
||||||
|
|
||||||
setItems(combined);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching history:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
useIonViewWillEnter(() => {
|
|
||||||
fetchData();
|
|
||||||
}, [fetchData]);
|
|
||||||
|
|
||||||
const handleRefresh = async (e: CustomEvent) => {
|
|
||||||
await fetchData();
|
|
||||||
e.detail.complete();
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
|
||||||
if (filter === 'all') return items;
|
|
||||||
if (filter === 'sales') return items.filter((i) => i.type === 'sale');
|
|
||||||
if (filter === 'credit') return items.filter((i) => i.type === 'credit');
|
|
||||||
if (filter === 'repayments')
|
|
||||||
return items.filter((i) => i.type === 'repayment');
|
|
||||||
return items;
|
|
||||||
}, [items, filter]);
|
|
||||||
|
|
||||||
const groupedItems = useMemo(() => {
|
|
||||||
return groupItemsByDate(filteredItems);
|
|
||||||
}, [filteredItems]);
|
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
|
||||||
const salesTotal = items
|
|
||||||
.filter((i) => i.type === 'sale')
|
|
||||||
.reduce((sum, i) => sum + i.amount, 0);
|
|
||||||
const repaymentsTotal = items
|
|
||||||
.filter((i) => i.type === 'repayment')
|
|
||||||
.reduce((sum, i) => sum + i.amount, 0);
|
|
||||||
return { salesTotal, repaymentsTotal };
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonPage>
|
|
||||||
<IonContent
|
|
||||||
fullscreen
|
|
||||||
style={{
|
|
||||||
'--background':
|
|
||||||
'linear-gradient(160deg, rgba(199,210,254,0.35) 0%, #f0f4ff 58%, #f8fafc 100%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
|
|
||||||
<IonRefresherContent />
|
|
||||||
</IonRefresher>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 16px',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h1 className="bold" style={{ margin: '0', fontSize: '20px' }}>
|
|
||||||
History
|
|
||||||
</h1>
|
|
||||||
<p className="secondary-text" style={{ margin: '0' }}>
|
|
||||||
All your transactions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="icon-container"
|
|
||||||
style={{
|
|
||||||
borderRadius: '12px',
|
|
||||||
background: '#ffffff',
|
|
||||||
color: 'var(--ion-color-primary)',
|
|
||||||
}}
|
|
||||||
onClick={() => setShowQuickAdd(true)}
|
|
||||||
>
|
|
||||||
<IonIcon icon={addOutline} style={{ fontSize: '22px' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="page-padding">
|
|
||||||
<IonSegment
|
|
||||||
value={filter}
|
|
||||||
onIonChange={(e) => setFilter(e.detail.value as any)}
|
|
||||||
style={{
|
|
||||||
'--background': 'var(--color-surface-secondary)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '4px',
|
|
||||||
marginBottom: '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonSegmentButton
|
|
||||||
value="all"
|
|
||||||
style={{
|
|
||||||
'--indicator-color': 'var(--ion-color-primary)',
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonLabel className="semibold">All</IonLabel>
|
|
||||||
</IonSegmentButton>
|
|
||||||
<IonSegmentButton value="sales">
|
|
||||||
<IonLabel className="semibold">Sales</IonLabel>
|
|
||||||
</IonSegmentButton>
|
|
||||||
<IonSegmentButton value="credit">
|
|
||||||
<IonLabel className="semibold">Credit</IonLabel>
|
|
||||||
</IonSegmentButton>
|
|
||||||
<IonSegmentButton value="repayments">
|
|
||||||
<IonLabel className="semibold">Paid</IonLabel>
|
|
||||||
</IonSegmentButton>
|
|
||||||
</IonSegment>
|
|
||||||
|
|
||||||
<div className="metric-pair-row">
|
|
||||||
<MetricCard
|
|
||||||
label="Total Sales"
|
|
||||||
value={totals.salesTotal}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Total Repaid"
|
|
||||||
value={totals.repaymentsTotal}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="page-padding"
|
|
||||||
style={{ paddingBottom: '120px', marginTop: '8px' }}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<div className="widget-card" style={{ gap: '16px' }}>
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '40px',
|
|
||||||
height: '40px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
background: 'var(--color-surface-secondary)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '60%',
|
|
||||||
height: '12px',
|
|
||||||
background: 'var(--color-surface-secondary)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
marginBottom: '6px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '40%',
|
|
||||||
height: '10px',
|
|
||||||
background: 'var(--color-surface-secondary)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : groupedItems.length > 0 ? (
|
|
||||||
groupedItems.map((group) => (
|
|
||||||
<div key={group.date} style={{ marginBottom: '20px' }}>
|
|
||||||
<div className="tertiary-text" style={{ marginBottom: '12px' }}>
|
|
||||||
{formatRelativeDate(group.date)}
|
|
||||||
</div>
|
|
||||||
<div className="widget-card" style={{ padding: '8px 16px' }}>
|
|
||||||
{group.items.map((item) => (
|
|
||||||
<ActivityRow
|
|
||||||
key={`${item.type}-${item.id}`}
|
|
||||||
item={item}
|
|
||||||
onClick={
|
|
||||||
item.customerId
|
|
||||||
? () => history.push(`/customers/${item.customerId}`)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon={receiptOutline}
|
|
||||||
title={
|
|
||||||
filter === 'all' ? 'No activity yet' : `No ${filter} found`
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
filter === 'all'
|
|
||||||
? 'Start recording sales and credits to see your history.'
|
|
||||||
: `You haven't recorded any ${filter} yet.`
|
|
||||||
}
|
|
||||||
actionText={
|
|
||||||
filter === 'all' || filter === 'sales'
|
|
||||||
? 'Add Sale'
|
|
||||||
: filter === 'credit'
|
|
||||||
? 'Add Credit'
|
|
||||||
: 'Record Repayment'
|
|
||||||
}
|
|
||||||
onAction={() => {
|
|
||||||
if (filter === 'sales' || filter === 'all')
|
|
||||||
history.push('/sale/new');
|
|
||||||
else if (filter === 'credit') history.push('/credit/new');
|
|
||||||
else history.push('/repayment/new');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<IonActionSheet
|
|
||||||
isOpen={showQuickAdd}
|
|
||||||
onDidDismiss={() => setShowQuickAdd(false)}
|
|
||||||
header="Quick Actions"
|
|
||||||
buttons={[
|
|
||||||
{
|
|
||||||
text: 'Add Sale',
|
|
||||||
icon: receiptOutline,
|
|
||||||
handler: () => history.push('/sale/new'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Add Credit',
|
|
||||||
icon: personAddOutline,
|
|
||||||
handler: () => history.push('/credit/new'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Record Repayment',
|
|
||||||
icon: cashOutline,
|
|
||||||
handler: () => history.push('/repayment/new'),
|
|
||||||
},
|
|
||||||
{ text: 'Cancel', role: 'cancel' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</IonContent>
|
|
||||||
</IonPage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HistoryPage;
|
|
||||||
+430
-297
@@ -1,356 +1,489 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
IonContent,
|
|
||||||
IonPage,
|
|
||||||
IonIcon,
|
|
||||||
IonButton,
|
IonButton,
|
||||||
IonActionSheet,
|
IonButtons,
|
||||||
|
IonContent,
|
||||||
|
IonHeader,
|
||||||
|
IonIcon,
|
||||||
|
IonPage,
|
||||||
IonRefresher,
|
IonRefresher,
|
||||||
IonRefresherContent,
|
IonRefresherContent,
|
||||||
useIonViewWillEnter,
|
IonSkeletonText,
|
||||||
|
IonToolbar,
|
||||||
} from '@ionic/react';
|
} from '@ionic/react';
|
||||||
import {
|
import {
|
||||||
addCircleOutline,
|
addOutline,
|
||||||
personAddOutline,
|
analyticsOutline,
|
||||||
cashOutline,
|
checkmarkCircle,
|
||||||
receiptOutline,
|
|
||||||
walletOutline,
|
|
||||||
checkmarkCircleOutline,
|
|
||||||
personCircleOutline,
|
personCircleOutline,
|
||||||
|
sparklesOutline,
|
||||||
} from 'ionicons/icons';
|
} from 'ionicons/icons';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { PushNotifications } from '@capacitor/push-notifications';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
import { Redirect, useHistory } from 'react-router-dom';
|
||||||
|
import type { Tables } from '../database.types';
|
||||||
import { supabase } from '../supabase';
|
import { supabase } from '../supabase';
|
||||||
import MetricCard from '../components/MetricCard';
|
|
||||||
import ActivityRow, { ActivityItem } from '../components/ActivityRow';
|
|
||||||
import EmptyState from '../components/EmptyState';
|
import EmptyState from '../components/EmptyState';
|
||||||
import { getTodayISO } from '../utils/dates';
|
import HabitCard from '../components/HabitCard';
|
||||||
|
import HabitFormModal from '../components/HabitFormModal';
|
||||||
|
|
||||||
|
interface HabitWithMeta extends Tables<'habits'> {
|
||||||
|
completions: Tables<'habit_completions'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const calculateCurrentStreak = (dates: string[]) => {
|
||||||
|
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = new Date();
|
||||||
|
|
||||||
|
if (!unique.includes(formatDate(cursor))) {
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (unique.includes(formatDate(cursor))) {
|
||||||
|
streak += 1;
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return streak;
|
||||||
|
};
|
||||||
|
|
||||||
const Home: React.FC = () => {
|
const Home: React.FC = () => {
|
||||||
const { user, profile } = useAuth();
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
|
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState('');
|
||||||
const [showQuickActions, setShowQuickActions] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [summary, setSummary] = useState({
|
const [busyHabitId, setBusyHabitId] = useState<string | null>(null);
|
||||||
todaySales: 0,
|
const pushTokenRef = useRef<string | null>(null);
|
||||||
outstandingCredit: 0,
|
const pushListenersReadyRef = useRef(false);
|
||||||
monthRepayments: 0,
|
|
||||||
recentActivity: [] as ActivityItem[],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const showError = (message: string) => {
|
||||||
if (!user) return;
|
setError(message);
|
||||||
setLoading(true);
|
window.setTimeout(() => setError(''), 4000);
|
||||||
setError(null);
|
};
|
||||||
|
|
||||||
try {
|
const registerPushNotifications = useCallback(
|
||||||
const today = getTodayISO();
|
async (currentUserId: string) => {
|
||||||
const firstOfMonth = new Date();
|
if (!Capacitor.isNativePlatform()) {
|
||||||
firstOfMonth.setDate(1);
|
return;
|
||||||
const firstOfMonthISO = firstOfMonth.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
// Parallel queries for efficiency
|
|
||||||
const [
|
|
||||||
{ data: salesToday },
|
|
||||||
{ data: creditsData },
|
|
||||||
{ data: repaymentsData },
|
|
||||||
{ data: recentSales },
|
|
||||||
{ data: recentCredits },
|
|
||||||
{ data: recentRepayments },
|
|
||||||
] = await Promise.all([
|
|
||||||
supabase.from('sales').select('amount').eq('sale_date', today),
|
|
||||||
supabase.from('credits').select('amount'),
|
|
||||||
supabase.from('repayments').select('amount, repayment_date'),
|
|
||||||
supabase
|
|
||||||
.from('sales')
|
|
||||||
.select('*')
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(5),
|
|
||||||
supabase
|
|
||||||
.from('credits')
|
|
||||||
.select('*, credit_customers(name)')
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(5),
|
|
||||||
supabase
|
|
||||||
.from('repayments')
|
|
||||||
.select('*, credit_customers(name)')
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(5),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Calculate totals
|
|
||||||
const todaySalesTotal = (salesToday || []).reduce(
|
|
||||||
(sum, s) => sum + Number(s.amount),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const totalCredits = (creditsData || []).reduce(
|
|
||||||
(sum, c) => sum + Number(c.amount),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const totalRepayments = (repaymentsData || []).reduce(
|
|
||||||
(sum, r) => sum + Number(r.amount),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const monthRepaymentsTotal = (repaymentsData || [])
|
|
||||||
.filter((r) => r.repayment_date >= firstOfMonthISO)
|
|
||||||
.reduce((sum, r) => sum + Number(r.amount), 0);
|
|
||||||
|
|
||||||
// Combine activity
|
|
||||||
const activity: ActivityItem[] = [
|
|
||||||
...(recentSales || []).map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
type: 'sale' as const,
|
|
||||||
amount: Number(s.amount),
|
|
||||||
date: s.sale_date,
|
|
||||||
note: s.note,
|
|
||||||
})),
|
|
||||||
...(recentCredits || []).map((c) => ({
|
|
||||||
id: c.id,
|
|
||||||
type: 'credit' as const,
|
|
||||||
amount: Number(c.amount),
|
|
||||||
date: c.credit_date,
|
|
||||||
customerName: (c.credit_customers as any)?.name,
|
|
||||||
note: c.description,
|
|
||||||
customerId: c.customer_id,
|
|
||||||
})),
|
|
||||||
...(recentRepayments || []).map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
type: 'repayment' as const,
|
|
||||||
amount: Number(r.amount),
|
|
||||||
date: r.repayment_date,
|
|
||||||
customerName: (r.credit_customers as any)?.name,
|
|
||||||
note: r.note,
|
|
||||||
customerId: r.customer_id,
|
|
||||||
})),
|
|
||||||
].sort((a, b) => b.id.localeCompare(a.id)); // Simplified sort for MVP, ideally use created_at
|
|
||||||
|
|
||||||
setSummary({
|
|
||||||
todaySales: todaySalesTotal,
|
|
||||||
outstandingCredit: Math.max(0, totalCredits - totalRepayments),
|
|
||||||
monthRepayments: monthRepaymentsTotal,
|
|
||||||
recentActivity: activity.slice(0, 10),
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error fetching dashboard data:', err);
|
|
||||||
setError('Could not load dashboard data');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
useIonViewWillEnter(() => {
|
if (!pushListenersReadyRef.current) {
|
||||||
fetchData();
|
pushListenersReadyRef.current = true;
|
||||||
}, [fetchData]);
|
|
||||||
|
|
||||||
const handleRefresh = async (e: CustomEvent) => {
|
await PushNotifications.removeAllListeners();
|
||||||
await fetchData();
|
|
||||||
e.detail.complete();
|
PushNotifications.addListener('registration', async (token) => {
|
||||||
|
if (pushTokenRef.current === token.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushTokenRef.current = token.value;
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.update({ fcm_token: token.value })
|
||||||
|
.eq('user_id', currentUserId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
showError(updateError.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
PushNotifications.addListener(
|
||||||
|
'registrationError',
|
||||||
|
(registrationError) => {
|
||||||
|
showError(registrationError.error ?? 'Push registration failed.');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionStatus = await PushNotifications.checkPermissions();
|
||||||
|
if (permissionStatus.receive === 'prompt') {
|
||||||
|
const requested = await PushNotifications.requestPermissions();
|
||||||
|
if (requested.receive !== 'granted') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (permissionStatus.receive !== 'granted') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await PushNotifications.register();
|
||||||
|
},
|
||||||
|
[showError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadDashboard = useCallback(async () => {
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
error: sessionError,
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (sessionError) {
|
||||||
|
setError(sessionError.message);
|
||||||
|
setSessionChecked(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
setUserId(null);
|
||||||
|
setSessionChecked(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserId(session.user.id);
|
||||||
|
setSessionChecked(true);
|
||||||
|
await registerPushNotifications(session.user.id);
|
||||||
|
|
||||||
|
const { data, error: habitsError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.select('*, completions:habit_completions(*)')
|
||||||
|
.order('created_at', { ascending: true });
|
||||||
|
|
||||||
|
if (habitsError) {
|
||||||
|
setError(habitsError.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHabits((data ?? []) as HabitWithMeta[]);
|
||||||
|
setLoading(false);
|
||||||
|
}, [registerPushNotifications]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDashboard();
|
||||||
|
}, [loadDashboard]);
|
||||||
|
|
||||||
|
const today = formatDate(new Date());
|
||||||
|
|
||||||
|
const habitStats = useMemo(() => {
|
||||||
|
const last7Days = Array.from({ length: 7 }, (_, index) => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - index);
|
||||||
|
return formatDate(date);
|
||||||
|
});
|
||||||
|
|
||||||
|
return habits.reduce<
|
||||||
|
Record<string, { streak: number; weekRate: number; doneToday: boolean }>
|
||||||
|
>((acc, habit) => {
|
||||||
|
const completionDates = habit.completions.map(
|
||||||
|
(entry) => entry.completed_on
|
||||||
|
);
|
||||||
|
const completedInWeek = completionDates.filter((date) =>
|
||||||
|
last7Days.includes(date)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
acc[habit.id] = {
|
||||||
|
streak: calculateCurrentStreak(completionDates),
|
||||||
|
weekRate: Math.round((completedInWeek / last7Days.length) * 100),
|
||||||
|
doneToday: completionDates.includes(today),
|
||||||
};
|
};
|
||||||
|
|
||||||
const getGreeting = () => {
|
return acc;
|
||||||
const hour = new Date().getHours();
|
}, {});
|
||||||
if (hour < 12) return 'Good morning';
|
}, [habits, today]);
|
||||||
if (hour < 17) return 'Good afternoon';
|
|
||||||
return 'Good evening';
|
const totalCompletedToday = habits.filter(
|
||||||
|
(habit) => habitStats[habit.id]?.doneToday
|
||||||
|
).length;
|
||||||
|
const longestStreak = habits.reduce(
|
||||||
|
(max, habit) => Math.max(max, habitStats[habit.id]?.streak ?? 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const completionRatio = habits.length
|
||||||
|
? totalCompletedToday / habits.length
|
||||||
|
: 0;
|
||||||
|
const pendingCount = Math.max(habits.length - totalCompletedToday, 0);
|
||||||
|
|
||||||
|
const createHabit = async (values: {
|
||||||
|
name: string;
|
||||||
|
targetDescription: string;
|
||||||
|
color: string;
|
||||||
|
}) => {
|
||||||
|
if (!userId) throw new Error('You need to be logged in.');
|
||||||
|
|
||||||
|
const { error: insertError } = await supabase.from('habits').insert({
|
||||||
|
user_id: userId,
|
||||||
|
name: values.name,
|
||||||
|
target_description: values.targetDescription || null,
|
||||||
|
color: values.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (insertError) throw insertError;
|
||||||
|
await loadDashboard();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleHabit = async (habit: HabitWithMeta) => {
|
||||||
|
if (!userId) return;
|
||||||
|
setBusyHabitId(habit.id);
|
||||||
|
|
||||||
|
const existing = habit.completions.find(
|
||||||
|
(entry) => entry.completed_on === today
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from('habit_completions')
|
||||||
|
.delete()
|
||||||
|
.eq('id', existing.id);
|
||||||
|
if (deleteError) showError(deleteError.message);
|
||||||
|
} else {
|
||||||
|
const { error: insertError } = await supabase
|
||||||
|
.from('habit_completions')
|
||||||
|
.insert({
|
||||||
|
habit_id: habit.id,
|
||||||
|
user_id: userId,
|
||||||
|
completed_on: today,
|
||||||
|
});
|
||||||
|
if (insertError) {
|
||||||
|
showError(insertError.message);
|
||||||
|
} else {
|
||||||
|
const { error: pushError } = await supabase.functions.invoke(
|
||||||
|
'send-habit-completion-push',
|
||||||
|
{
|
||||||
|
body: { habitId: habit.id },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pushError) {
|
||||||
|
showError(pushError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadDashboard();
|
||||||
|
setBusyHabitId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteHabit = async (habitId: string) => {
|
||||||
|
setBusyHabitId(habitId);
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.delete()
|
||||||
|
.eq('id', habitId);
|
||||||
|
if (deleteError) {
|
||||||
|
showError(deleteError.message);
|
||||||
|
}
|
||||||
|
await loadDashboard();
|
||||||
|
setBusyHabitId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
void PushNotifications.removeAllListeners();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (sessionChecked && !userId) {
|
||||||
|
return <Redirect to="/auth" />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IonPage>
|
<IonPage>
|
||||||
<IonContent
|
<IonHeader className="ion-no-border home-header">
|
||||||
fullscreen
|
<IonToolbar>
|
||||||
style={{
|
<IonButtons slot="end">
|
||||||
'--background':
|
<IonButton
|
||||||
'linear-gradient(160deg, rgba(199,210,254,0.45) 0%, #f0f4ff 58%, #f8fafc 100%)',
|
className="soft-icon-button"
|
||||||
|
onClick={() => history.push('/stats')}
|
||||||
|
>
|
||||||
|
<IonIcon icon={analyticsOutline} />
|
||||||
|
</IonButton>
|
||||||
|
<IonButton
|
||||||
|
className="soft-icon-button"
|
||||||
|
onClick={() => history.push('/account')}
|
||||||
|
>
|
||||||
|
<IonIcon icon={personCircleOutline} />
|
||||||
|
</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
|
||||||
|
<IonContent fullscreen className="home-content">
|
||||||
|
<IonRefresher
|
||||||
|
slot="fixed"
|
||||||
|
onIonRefresh={async (e) => {
|
||||||
|
await loadDashboard();
|
||||||
|
e.detail.complete();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
|
|
||||||
<IonRefresherContent />
|
<IonRefresherContent />
|
||||||
</IonRefresher>
|
</IonRefresher>
|
||||||
|
|
||||||
<div
|
<div className="dashboard-shell home-shell ion-padding">
|
||||||
style={{
|
<section className="habits-hero">
|
||||||
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 16px',
|
<div className="habits-hero-top">
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="secondary-text">{getGreeting()}</div>
|
<p className="hero-kicker">Habits ({habits.length})</p>
|
||||||
<h1 className="bold" style={{ margin: '0', fontSize: '20px' }}>
|
<div className="hero-title-wrap">
|
||||||
{profile?.shop_name || profile?.full_name || 'My Shop'}
|
<h1>Daily</h1>
|
||||||
</h1>
|
<span>Weekly</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<IonButton
|
||||||
|
className="hero-add-button"
|
||||||
|
fill="clear"
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
>
|
||||||
|
<IonIcon icon={addOutline} />
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-progress-track" aria-hidden="true">
|
||||||
<div
|
<div
|
||||||
className="icon-container"
|
className="hero-progress-fill"
|
||||||
style={{
|
style={{ width: `${Math.max(completionRatio * 100, 6)}%` }}
|
||||||
borderRadius: '999px',
|
|
||||||
background: 'var(--color-accent-soft)',
|
|
||||||
}}
|
|
||||||
onClick={() => setShowQuickActions(true)}
|
|
||||||
>
|
>
|
||||||
<IonIcon icon={personCircleOutline} style={{ fontSize: '24px' }} />
|
<span className="hero-progress-dot" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-padding">
|
<div className="hero-progress-meta">
|
||||||
<MetricCard
|
<span>{totalCompletedToday} completed today</span>
|
||||||
label="Today's Sales"
|
<span>{Math.round(completionRatio * 100)}% completed</span>
|
||||||
value={summary.todaySales}
|
|
||||||
fullWidth
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="metric-pair-row" style={{ marginTop: '12px' }}>
|
|
||||||
<MetricCard
|
|
||||||
label="Outstanding"
|
|
||||||
value={summary.outstandingCredit}
|
|
||||||
icon={walletOutline}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Repaid (Month)"
|
|
||||||
value={summary.monthRepayments}
|
|
||||||
icon={checkmarkCircleOutline}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div
|
|
||||||
className="pill-toggle"
|
|
||||||
style={{ marginTop: '12px', background: '#ffffff', padding: '8px' }}
|
|
||||||
>
|
|
||||||
<IonButton
|
|
||||||
fill="clear"
|
|
||||||
className="bold"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: '13px',
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
}}
|
|
||||||
onClick={() => history.push('/sale/new')}
|
|
||||||
>
|
|
||||||
<IonIcon icon={addCircleOutline} slot="start" />
|
|
||||||
Add Sale
|
|
||||||
</IonButton>
|
|
||||||
<IonButton
|
|
||||||
fill="clear"
|
|
||||||
className="bold"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: '13px',
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
}}
|
|
||||||
onClick={() => history.push('/credit/new')}
|
|
||||||
>
|
|
||||||
<IonIcon icon={personAddOutline} slot="start" />
|
|
||||||
Credit
|
|
||||||
</IonButton>
|
|
||||||
<IonButton
|
|
||||||
fill="clear"
|
|
||||||
className="bold"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: '13px',
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
}}
|
|
||||||
onClick={() => history.push('/repayment/new')}
|
|
||||||
>
|
|
||||||
<IonIcon icon={cashOutline} slot="start" />
|
|
||||||
Repay
|
|
||||||
</IonButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="section-header">
|
|
||||||
<h2>Recent activity</h2>
|
|
||||||
<span className="see-all" onClick={() => history.push('/history')}>
|
|
||||||
See all
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="page-padding" style={{ paddingBottom: '120px' }}>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="widget-card" style={{ gap: '16px' }}>
|
<div className="home-routine-panel">
|
||||||
{[1, 2, 3].map((i) => (
|
<div className="stack-list home-habit-stack">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
className="habit-card habit-card--editorial habit-card-skeleton"
|
||||||
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
|
key={`habit-skeleton-${index}`}
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<div
|
<IonSkeletonText
|
||||||
style={{
|
animated
|
||||||
width: '40px',
|
className="habit-skeleton-check"
|
||||||
height: '40px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
background: 'var(--color-surface-secondary)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div
|
<div className="habit-card-main">
|
||||||
style={{
|
<div className="habit-copy">
|
||||||
width: '60%',
|
<div className="habit-copy-topline">
|
||||||
height: '12px',
|
<IonSkeletonText
|
||||||
background: 'var(--color-surface-secondary)',
|
animated
|
||||||
borderRadius: '4px',
|
className="habit-skeleton-title"
|
||||||
marginBottom: '6px',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div
|
<IonSkeletonText
|
||||||
style={{
|
animated
|
||||||
width: '40%',
|
className="habit-skeleton-color-pill"
|
||||||
height: '10px',
|
|
||||||
background: 'var(--color-surface-secondary)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
className="habit-skeleton-description"
|
||||||
|
/>
|
||||||
|
<div className="habit-metrics-row">
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
className="habit-skeleton-metric"
|
||||||
|
/>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
className="habit-skeleton-metric short"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
className="habit-skeleton-action"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : summary.recentActivity.length > 0 ? (
|
</div>
|
||||||
<div className="widget-card" style={{ padding: '8px 16px' }}>
|
) : error ? (
|
||||||
{summary.recentActivity.map((item) => (
|
<div className="inline-message error-card home-inline-card">
|
||||||
<ActivityRow
|
<h3>Couldn't load your habits</h3>
|
||||||
key={`${item.type}-${item.id}`}
|
<p>{error}</p>
|
||||||
item={item}
|
<IonButton onClick={loadDashboard}>Try Again</IonButton>
|
||||||
onClick={
|
</div>
|
||||||
item.customerId
|
) : habits.length === 0 ? (
|
||||||
? () => history.push(`/customers/${item.customerId}`)
|
<div className="home-routine-panel">
|
||||||
: undefined
|
<EmptyState
|
||||||
}
|
title="Start your first routine"
|
||||||
|
message="Create one small daily habit and begin building your streak story today."
|
||||||
|
actionLabel="Create First Habit"
|
||||||
|
onAction={() => setShowCreateModal(true)}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<>
|
||||||
icon={receiptOutline}
|
<section className="home-routine-panel">
|
||||||
title="No activity yet"
|
<div className="stack-list home-habit-stack">
|
||||||
description="Record your first sale or credit to see it here."
|
{habits.map((habit) => (
|
||||||
actionText="Add Sale"
|
<HabitCard
|
||||||
onAction={() => history.push('/sale/new')}
|
key={habit.id}
|
||||||
|
habit={habit}
|
||||||
|
completedToday={habitStats[habit.id]?.doneToday ?? false}
|
||||||
|
currentStreak={habitStats[habit.id]?.streak ?? 0}
|
||||||
|
completionRate={habitStats[habit.id]?.weekRate ?? 0}
|
||||||
|
onToggle={() => toggleHabit(habit)}
|
||||||
|
onDelete={() => deleteHabit(habit.id)}
|
||||||
|
busy={busyHabitId === habit.id}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="home-complete-section">
|
||||||
|
<p className="home-section-label">Momentum</p>
|
||||||
|
<div className="home-summary-card">
|
||||||
|
<div
|
||||||
|
className="summary-ring"
|
||||||
|
style={{
|
||||||
|
background: `conic-gradient(#7c3aed 0deg, #a855f7 ${completionRatio * 360}deg, rgba(109, 40, 217, 0.08) ${completionRatio * 360}deg 360deg)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="summary-ring-inner">
|
||||||
|
{Math.round(completionRatio * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-copy">
|
||||||
|
<h3>Keep your rhythm</h3>
|
||||||
|
<p>
|
||||||
|
{pendingCount === 0
|
||||||
|
? 'Everything is complete today. Enjoy the streak.'
|
||||||
|
: `${pendingCount} habit${pendingCount === 1 ? '' : 's'} left to protect today's routine.`}
|
||||||
|
</p>
|
||||||
|
<div className="summary-metrics">
|
||||||
|
<span>
|
||||||
|
<IonIcon icon={sparklesOutline} /> {totalCompletedToday}{' '}
|
||||||
|
done
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<IonIcon icon={checkmarkCircle} /> {longestStreak} day
|
||||||
|
best streak
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IonActionSheet
|
<HabitFormModal
|
||||||
isOpen={showQuickActions}
|
isOpen={showCreateModal}
|
||||||
onDidDismiss={() => setShowQuickActions(false)}
|
onDismiss={() => setShowCreateModal(false)}
|
||||||
header="Settings"
|
onSubmit={createHabit}
|
||||||
buttons={[
|
|
||||||
{
|
|
||||||
text: 'Sign Out',
|
|
||||||
role: 'destructive',
|
|
||||||
handler: () => {
|
|
||||||
supabase.auth.signOut();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Cancel',
|
|
||||||
role: 'cancel',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</IonContent>
|
</IonContent>
|
||||||
</IonPage>
|
</IonPage>
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
IonContent,
|
|
||||||
IonPage,
|
|
||||||
IonInput,
|
|
||||||
IonButton,
|
|
||||||
IonSpinner,
|
|
||||||
IonIcon,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import { chevronBackOutline } from 'ionicons/icons';
|
|
||||||
import { useHistory } from 'react-router-dom';
|
|
||||||
import { supabase } from '../supabase';
|
|
||||||
|
|
||||||
const NewPasswordPage: React.FC = () => {
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const history = useHistory();
|
|
||||||
|
|
||||||
const showMessage = (msg: string) => {
|
|
||||||
setMessage(msg);
|
|
||||||
setTimeout(() => setMessage(null), 4000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!password || !confirmPassword) {
|
|
||||||
showMessage('Please fill in all fields');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 6) {
|
|
||||||
showMessage('Password must be at least 6 characters');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
showMessage('Passwords do not match');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { error } = await supabase.auth.updateUser({ password });
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
history.push('/home');
|
|
||||||
} catch (err: any) {
|
|
||||||
showMessage(err.message || 'An error occurred');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonPage>
|
|
||||||
<IonContent
|
|
||||||
fullscreen
|
|
||||||
style={{
|
|
||||||
'--background':
|
|
||||||
'linear-gradient(160deg, rgba(199,210,254,0.52) 0%, #f0f4ff 58%, #f8fafc 100%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="auth-shell">
|
|
||||||
<IonButton
|
|
||||||
fill="clear"
|
|
||||||
color="dark"
|
|
||||||
onClick={() => history.push('/auth')}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 'calc(var(--ion-safe-area-top, 0px) + 8px)',
|
|
||||||
left: '8px',
|
|
||||||
'--padding-start': '8px',
|
|
||||||
'--padding-end': '8px',
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#ffffff',
|
|
||||||
width: '44px',
|
|
||||||
height: '44px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon icon={chevronBackOutline} />
|
|
||||||
</div>
|
|
||||||
</IonButton>
|
|
||||||
|
|
||||||
<div style={{ marginTop: '80px' }}>
|
|
||||||
<h1
|
|
||||||
className="bold"
|
|
||||||
style={{ fontSize: '24px', margin: '0 0 8px 0' }}
|
|
||||||
>
|
|
||||||
Create new password
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
className="secondary-text"
|
|
||||||
style={{ margin: '0', fontSize: '14px' }}
|
|
||||||
>
|
|
||||||
Set a strong password to protect your account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="auth-card">
|
|
||||||
<form
|
|
||||||
onSubmit={handleUpdate}
|
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
|
|
||||||
>
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<IonInput
|
|
||||||
label="New Password"
|
|
||||||
labelPlacement="floating"
|
|
||||||
type="password"
|
|
||||||
placeholder="Min. 6 characters"
|
|
||||||
value={password}
|
|
||||||
onIonChange={(e) => setPassword(e.detail.value!)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<IonInput
|
|
||||||
label="Confirm Password"
|
|
||||||
labelPlacement="floating"
|
|
||||||
type="password"
|
|
||||||
placeholder="Repeat your password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onIonChange={(e) => setConfirmPassword(e.detail.value!)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'var(--color-danger-soft)',
|
|
||||||
color: 'var(--ion-color-danger-shade)',
|
|
||||||
padding: '12px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
fontSize: '13px',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IonButton
|
|
||||||
type="submit"
|
|
||||||
expand="block"
|
|
||||||
className="bold"
|
|
||||||
style={{
|
|
||||||
height: '52px',
|
|
||||||
'--border-radius': 'var(--radius-pill)',
|
|
||||||
marginTop: '8px',
|
|
||||||
}}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
<IonSpinner name="crescent" />
|
|
||||||
) : (
|
|
||||||
'Update password'
|
|
||||||
)}
|
|
||||||
</IonButton>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</IonContent>
|
|
||||||
</IonPage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NewPasswordPage;
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
IonContent,
|
|
||||||
IonPage,
|
|
||||||
IonHeader,
|
|
||||||
IonToolbar,
|
|
||||||
IonTitle,
|
|
||||||
IonButtons,
|
|
||||||
IonButton,
|
|
||||||
IonIcon,
|
|
||||||
IonInput,
|
|
||||||
IonTextarea,
|
|
||||||
IonSpinner,
|
|
||||||
IonLabel,
|
|
||||||
IonSelect,
|
|
||||||
IonSelectOption,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import { chevronBackOutline } from 'ionicons/icons';
|
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
|
||||||
import { supabase } from '../supabase';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
import { getTodayISO } from '../utils/dates';
|
|
||||||
import MoneyInput from '../components/MoneyInput';
|
|
||||||
import { parseMoney, formatMoney } from '../utils/money';
|
|
||||||
import EmptyState from '../components/EmptyState';
|
|
||||||
|
|
||||||
interface CustomerWithBalance {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
balance: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RepaymentFormPage: React.FC = () => {
|
|
||||||
const history = useHistory();
|
|
||||||
const location = useLocation<{ customerId?: string }>();
|
|
||||||
const { user, profile } = useAuth();
|
|
||||||
const currency = profile?.currency || 'USD';
|
|
||||||
|
|
||||||
const [customers, setCustomers] = useState<CustomerWithBalance[]>([]);
|
|
||||||
const [selectedCustomerId, setSelectedCustomerId] = useState<string>(
|
|
||||||
location.state?.customerId || ''
|
|
||||||
);
|
|
||||||
const [amount, setAmount] = useState('');
|
|
||||||
const [repaymentDate, setRepaymentDate] = useState(getTodayISO());
|
|
||||||
const [note, setNote] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [loadingCustomers, setLoadingCustomers] = useState(true);
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchCustomersAndBalances = async () => {
|
|
||||||
if (!user) return;
|
|
||||||
try {
|
|
||||||
const [
|
|
||||||
{ data: customersData },
|
|
||||||
{ data: creditsData },
|
|
||||||
{ data: repaymentsData },
|
|
||||||
] = await Promise.all([
|
|
||||||
supabase
|
|
||||||
.from('credit_customers')
|
|
||||||
.select('id, name')
|
|
||||||
.eq('user_id', user.id),
|
|
||||||
supabase
|
|
||||||
.from('credits')
|
|
||||||
.select('customer_id, amount')
|
|
||||||
.eq('user_id', user.id),
|
|
||||||
supabase
|
|
||||||
.from('repayments')
|
|
||||||
.select('customer_id, amount')
|
|
||||||
.eq('user_id', user.id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const customerBalances: CustomerWithBalance[] = (
|
|
||||||
customersData || []
|
|
||||||
).map((c) => {
|
|
||||||
const totalCredits = (creditsData || [])
|
|
||||||
.filter((cr) => cr.customer_id === c.id)
|
|
||||||
.reduce((sum, cr) => sum + Number(cr.amount), 0);
|
|
||||||
const totalRepayments = (repaymentsData || [])
|
|
||||||
.filter((r) => r.customer_id === c.id)
|
|
||||||
.reduce((sum, r) => sum + Number(r.amount), 0);
|
|
||||||
return {
|
|
||||||
id: c.id,
|
|
||||||
name: c.name,
|
|
||||||
balance: Math.max(0, totalCredits - totalRepayments),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter for customers with an outstanding balance
|
|
||||||
const activeCustomers = customerBalances.filter((c) => c.balance > 0);
|
|
||||||
setCustomers(activeCustomers);
|
|
||||||
|
|
||||||
// If a customer was passed in but has no balance, we should handle it
|
|
||||||
if (
|
|
||||||
selectedCustomerId &&
|
|
||||||
!activeCustomers.find((c) => c.id === selectedCustomerId)
|
|
||||||
) {
|
|
||||||
setSelectedCustomerId('');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching customers for repayment:', err);
|
|
||||||
} finally {
|
|
||||||
setLoadingCustomers(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchCustomersAndBalances();
|
|
||||||
}, [user, selectedCustomerId]);
|
|
||||||
|
|
||||||
const selectedCustomer = useMemo(
|
|
||||||
() => customers.find((c) => c.id === selectedCustomerId),
|
|
||||||
[customers, selectedCustomerId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const validate = () => {
|
|
||||||
const newErrors: Record<string, string> = {};
|
|
||||||
const parsedAmount = parseMoney(amount);
|
|
||||||
|
|
||||||
if (!selectedCustomerId) {
|
|
||||||
newErrors.customer = 'Please select a customer';
|
|
||||||
} else if (selectedCustomer && parsedAmount > selectedCustomer.balance) {
|
|
||||||
newErrors.amount = `Amount cannot exceed outstanding balance of ${formatMoney(
|
|
||||||
selectedCustomer.balance,
|
|
||||||
currency
|
|
||||||
)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedAmount <= 0) {
|
|
||||||
newErrors.amount = 'Amount must be greater than 0';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!repaymentDate) {
|
|
||||||
newErrors.date = 'Date is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!validate() || !user) return;
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { error } = await supabase.from('repayments').insert([
|
|
||||||
{
|
|
||||||
user_id: user.id,
|
|
||||||
customer_id: selectedCustomerId,
|
|
||||||
amount: parseMoney(amount),
|
|
||||||
repayment_date: repaymentDate,
|
|
||||||
note: note.trim() || null,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
history.goBack();
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error saving repayment:', err);
|
|
||||||
setMessage(err.message || 'Could not save repayment');
|
|
||||||
setTimeout(() => setMessage(null), 4000);
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonPage>
|
|
||||||
<IonHeader className="ion-no-border">
|
|
||||||
<IonToolbar style={{ '--background': '#f0f4ff' }}>
|
|
||||||
<IonButtons slot="start">
|
|
||||||
<IonButton color="dark" onClick={() => history.goBack()}>
|
|
||||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
|
||||||
</IonButton>
|
|
||||||
</IonButtons>
|
|
||||||
<IonTitle className="bold">Record repayment</IonTitle>
|
|
||||||
</IonToolbar>
|
|
||||||
</IonHeader>
|
|
||||||
|
|
||||||
<IonContent className="ion-padding" style={{ '--background': '#f0f4ff' }}>
|
|
||||||
{!loadingCustomers && customers.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon="wallet-outline"
|
|
||||||
title="No outstanding credit"
|
|
||||||
description="You don't have any customers with an outstanding balance right now."
|
|
||||||
actionText="Add Credit"
|
|
||||||
onAction={() => history.push('/credit/new')}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="form-card">
|
|
||||||
<div className="form-field">
|
|
||||||
<IonLabel
|
|
||||||
className="semibold"
|
|
||||||
style={{
|
|
||||||
marginBottom: '8px',
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Select Customer
|
|
||||||
</IonLabel>
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<IonSelect
|
|
||||||
interface="action-sheet"
|
|
||||||
placeholder="Choose a customer"
|
|
||||||
value={selectedCustomerId}
|
|
||||||
onIonChange={(e) => setSelectedCustomerId(e.detail.value)}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
disabled={loadingCustomers}
|
|
||||||
>
|
|
||||||
{customers.map((c) => (
|
|
||||||
<IonSelectOption key={c.id} value={c.id}>
|
|
||||||
{c.name} ({formatMoney(c.balance, currency)})
|
|
||||||
</IonSelectOption>
|
|
||||||
))}
|
|
||||||
</IonSelect>
|
|
||||||
</div>
|
|
||||||
{errors.customer && (
|
|
||||||
<IonLabel
|
|
||||||
color="danger"
|
|
||||||
style={{ fontSize: '12px', marginTop: '4px' }}
|
|
||||||
>
|
|
||||||
{errors.customer}
|
|
||||||
</IonLabel>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedCustomer && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'rgba(99,102,241,0.10)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '16px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="tertiary-text" style={{ fontSize: '10px' }}>
|
|
||||||
OUTSTANDING BALANCE
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="bold tabular"
|
|
||||||
style={{
|
|
||||||
fontSize: '22px',
|
|
||||||
color: 'var(--ion-color-primary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatMoney(selectedCustomer.balance, currency)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MoneyInput
|
|
||||||
label="Repayment amount"
|
|
||||||
value={amount}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
setAmount(val);
|
|
||||||
if (errors.amount) setErrors({ ...errors, amount: '' });
|
|
||||||
}}
|
|
||||||
error={errors.amount}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="form-field">
|
|
||||||
<IonLabel
|
|
||||||
className="semibold"
|
|
||||||
style={{
|
|
||||||
marginBottom: '8px',
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Repayment date
|
|
||||||
</IonLabel>
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<IonInput
|
|
||||||
type="date"
|
|
||||||
value={repaymentDate}
|
|
||||||
onIonChange={(e) => setRepaymentDate(e.detail.value!)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.date && (
|
|
||||||
<IonLabel
|
|
||||||
color="danger"
|
|
||||||
style={{ fontSize: '12px', marginTop: '4px' }}
|
|
||||||
>
|
|
||||||
{errors.date}
|
|
||||||
</IonLabel>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-field">
|
|
||||||
<IonLabel
|
|
||||||
className="semibold"
|
|
||||||
style={{
|
|
||||||
marginBottom: '8px',
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Note (optional)
|
|
||||||
</IonLabel>
|
|
||||||
<div
|
|
||||||
className="input-wrapper"
|
|
||||||
style={{ minHeight: '96px', padding: '8px 16px' }}
|
|
||||||
>
|
|
||||||
<IonTextarea
|
|
||||||
placeholder="e.g. Paid in full"
|
|
||||||
value={note}
|
|
||||||
onIonChange={(e) => setNote(e.detail.value!)}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'var(--color-danger-soft)',
|
|
||||||
color: 'var(--ion-color-danger-shade)',
|
|
||||||
padding: '12px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IonButton
|
|
||||||
expand="block"
|
|
||||||
className="bold"
|
|
||||||
style={{
|
|
||||||
height: '52px',
|
|
||||||
'--border-radius': 'var(--radius-pill)',
|
|
||||||
marginTop: '16px',
|
|
||||||
}}
|
|
||||||
disabled={submitting || loadingCustomers}
|
|
||||||
onClick={handleSave}
|
|
||||||
>
|
|
||||||
{submitting ? <IonSpinner name="crescent" /> : 'Save repayment'}
|
|
||||||
</IonButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</IonContent>
|
|
||||||
</IonPage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RepaymentFormPage;
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
IonContent,
|
|
||||||
IonPage,
|
|
||||||
IonHeader,
|
|
||||||
IonToolbar,
|
|
||||||
IonTitle,
|
|
||||||
IonButtons,
|
|
||||||
IonButton,
|
|
||||||
IonIcon,
|
|
||||||
IonInput,
|
|
||||||
IonTextarea,
|
|
||||||
IonSpinner,
|
|
||||||
IonLabel,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import { chevronBackOutline } from 'ionicons/icons';
|
|
||||||
import { useHistory } from 'react-router-dom';
|
|
||||||
import { supabase } from '../supabase';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
import { getTodayISO } from '../utils/dates';
|
|
||||||
import MoneyInput from '../components/MoneyInput';
|
|
||||||
import { parseMoney } from '../utils/money';
|
|
||||||
|
|
||||||
const SaleFormPage: React.FC = () => {
|
|
||||||
const history = useHistory();
|
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
const [amount, setAmount] = useState('');
|
|
||||||
const [saleDate, setSaleDate] = useState(getTodayISO());
|
|
||||||
const [note, setNote] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const validate = () => {
|
|
||||||
const newErrors: Record<string, string> = {};
|
|
||||||
const parsedAmount = parseMoney(amount);
|
|
||||||
|
|
||||||
if (parsedAmount <= 0) {
|
|
||||||
newErrors.amount = 'Amount must be greater than 0';
|
|
||||||
}
|
|
||||||
if (!saleDate) {
|
|
||||||
newErrors.date = 'Date is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!validate() || !user) return;
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { error } = await supabase.from('sales').insert([
|
|
||||||
{
|
|
||||||
user_id: user.id,
|
|
||||||
amount: parseMoney(amount),
|
|
||||||
sale_date: saleDate,
|
|
||||||
note: note.trim() || null,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
history.goBack();
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error saving sale:', err);
|
|
||||||
setMessage(err.message || 'Could not save sale');
|
|
||||||
setTimeout(() => setMessage(null), 4000);
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonPage>
|
|
||||||
<IonHeader className="ion-no-border">
|
|
||||||
<IonToolbar style={{ '--background': '#f0f4ff' }}>
|
|
||||||
<IonButtons slot="start">
|
|
||||||
<IonButton color="dark" onClick={() => history.goBack()}>
|
|
||||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
|
||||||
</IonButton>
|
|
||||||
</IonButtons>
|
|
||||||
<IonTitle className="bold">Add sale</IonTitle>
|
|
||||||
</IonToolbar>
|
|
||||||
</IonHeader>
|
|
||||||
|
|
||||||
<IonContent className="ion-padding" style={{ '--background': '#f0f4ff' }}>
|
|
||||||
<div className="form-card">
|
|
||||||
<MoneyInput
|
|
||||||
label="Sale amount"
|
|
||||||
value={amount}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
setAmount(val);
|
|
||||||
if (errors.amount) setErrors({ ...errors, amount: '' });
|
|
||||||
}}
|
|
||||||
error={errors.amount}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="form-field">
|
|
||||||
<IonLabel
|
|
||||||
className="semibold"
|
|
||||||
style={{
|
|
||||||
marginBottom: '8px',
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Sale date
|
|
||||||
</IonLabel>
|
|
||||||
<div className="input-wrapper">
|
|
||||||
<IonInput
|
|
||||||
type="date"
|
|
||||||
value={saleDate}
|
|
||||||
onIonChange={(e) => setSaleDate(e.detail.value!)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.date && (
|
|
||||||
<IonLabel
|
|
||||||
color="danger"
|
|
||||||
style={{ fontSize: '12px', marginTop: '4px' }}
|
|
||||||
>
|
|
||||||
{errors.date}
|
|
||||||
</IonLabel>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-field">
|
|
||||||
<IonLabel
|
|
||||||
className="semibold"
|
|
||||||
style={{
|
|
||||||
marginBottom: '8px',
|
|
||||||
display: 'block',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Note (optional)
|
|
||||||
</IonLabel>
|
|
||||||
<div
|
|
||||||
className="input-wrapper"
|
|
||||||
style={{ minHeight: '96px', padding: '8px 16px' }}
|
|
||||||
>
|
|
||||||
<IonTextarea
|
|
||||||
placeholder="e.g. Morning rush items"
|
|
||||||
value={note}
|
|
||||||
onIonChange={(e) => setNote(e.detail.value!)}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'var(--color-danger-soft)',
|
|
||||||
color: 'var(--ion-color-danger-shade)',
|
|
||||||
padding: '12px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IonButton
|
|
||||||
expand="block"
|
|
||||||
className="bold"
|
|
||||||
style={{
|
|
||||||
height: '52px',
|
|
||||||
'--border-radius': 'var(--radius-pill)',
|
|
||||||
marginTop: '16px',
|
|
||||||
}}
|
|
||||||
disabled={submitting}
|
|
||||||
onClick={handleSave}
|
|
||||||
>
|
|
||||||
{submitting ? <IonSpinner name="crescent" /> : 'Save sale'}
|
|
||||||
</IonButton>
|
|
||||||
</div>
|
|
||||||
</IonContent>
|
|
||||||
</IonPage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SaleFormPage;
|
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IonButton,
|
||||||
|
IonButtons,
|
||||||
|
IonContent,
|
||||||
|
IonHeader,
|
||||||
|
IonIcon,
|
||||||
|
IonPage,
|
||||||
|
IonSegment,
|
||||||
|
IonSegmentButton,
|
||||||
|
IonTitle,
|
||||||
|
IonToolbar,
|
||||||
|
} from '@ionic/react';
|
||||||
|
import { IonBackButton } from '@ionic/react';
|
||||||
|
import { calendarOutline } from 'ionicons/icons';
|
||||||
|
import { Redirect } from 'react-router-dom';
|
||||||
|
import type { Tables } from '../database.types';
|
||||||
|
import StatsHabitCard from '../components/StatsHabitCard';
|
||||||
|
import { supabase } from '../supabase';
|
||||||
|
import {
|
||||||
|
Style,
|
||||||
|
setStatusBarBackground,
|
||||||
|
setStatusBarStyle,
|
||||||
|
} from '../utils/statusBar';
|
||||||
|
import { useIonViewWillEnter } from '@ionic/react';
|
||||||
|
|
||||||
|
interface HabitWithMeta extends Tables<'habits'> {
|
||||||
|
completions: Tables<'habit_completions'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const calculateCurrentStreak = (dates: string[]) => {
|
||||||
|
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = new Date();
|
||||||
|
|
||||||
|
if (!unique.includes(formatDate(cursor))) {
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (unique.includes(formatDate(cursor))) {
|
||||||
|
streak += 1;
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return streak;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stats: React.FC = () => {
|
||||||
|
const [period, setPeriod] = useState<'7' | '30'>('7');
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
|
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
setSessionChecked(true);
|
||||||
|
setUserId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserId(session.user.id);
|
||||||
|
setSessionChecked(true);
|
||||||
|
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.select('*, completions:habit_completions(*)')
|
||||||
|
.order('created_at', { ascending: true });
|
||||||
|
|
||||||
|
setHabits((data ?? []) as HabitWithMeta[]);
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const days = Number(period);
|
||||||
|
const visibleDates = useMemo(() => {
|
||||||
|
return Array.from({ length: days }, (_, index) => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - index);
|
||||||
|
return formatDate(date);
|
||||||
|
});
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
return habits.map((habit) => {
|
||||||
|
const completionDates = habit.completions.map(
|
||||||
|
(entry) => entry.completed_on,
|
||||||
|
);
|
||||||
|
const completedCount = completionDates.filter((date) =>
|
||||||
|
visibleDates.includes(date),
|
||||||
|
).length;
|
||||||
|
return {
|
||||||
|
id: habit.id,
|
||||||
|
name: habit.name,
|
||||||
|
color: habit.color,
|
||||||
|
completedCount,
|
||||||
|
streak: calculateCurrentStreak(completionDates),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [habits, visibleDates]);
|
||||||
|
|
||||||
|
if (sessionChecked && !userId) {
|
||||||
|
return <Redirect to="/auth" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader className="ion-no-border">
|
||||||
|
<IonToolbar>
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonBackButton defaultHref="/home" />
|
||||||
|
</IonButtons>
|
||||||
|
<IonTitle>Stats</IonTitle>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent fullscreen>
|
||||||
|
<div className="dashboard-shell ion-padding">
|
||||||
|
<section className="hero-card compact">
|
||||||
|
<span className="eyebrow">Consistency view</span>
|
||||||
|
<h1>See which habits you're actually keeping.</h1>
|
||||||
|
<p>
|
||||||
|
Flip between the last week and month to spot patterns before a
|
||||||
|
streak disappears.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="stats-filter-row">
|
||||||
|
<IonIcon icon={calendarOutline} />
|
||||||
|
<IonSegment
|
||||||
|
value={period}
|
||||||
|
onIonChange={(e) => setPeriod(e.detail.value as '7' | '30')}
|
||||||
|
>
|
||||||
|
<IonSegmentButton value="7">Last 7 days</IonSegmentButton>
|
||||||
|
<IonSegmentButton value="30">Last 30 days</IonSegmentButton>
|
||||||
|
</IonSegment>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary.length === 0 ? (
|
||||||
|
<div className="inline-message">
|
||||||
|
<h3>No stats yet</h3>
|
||||||
|
<p>
|
||||||
|
Create a habit and start checking it off daily to unlock
|
||||||
|
consistency insights.
|
||||||
|
</p>
|
||||||
|
<IonButton routerLink="/home">Go to dashboard</IonButton>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="stack-list">
|
||||||
|
{summary.map((habit) => (
|
||||||
|
<StatsHabitCard
|
||||||
|
key={habit.id}
|
||||||
|
name={habit.name}
|
||||||
|
color={habit.color}
|
||||||
|
completedCount={habit.completedCount}
|
||||||
|
totalDays={days}
|
||||||
|
streak={habit.streak}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Stats;
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
IonContent,
|
|
||||||
IonPage,
|
|
||||||
IonInput,
|
|
||||||
IonButton,
|
|
||||||
IonSpinner,
|
|
||||||
IonIcon,
|
|
||||||
IonText,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import { chevronBackOutline } from 'ionicons/icons';
|
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
|
||||||
import { supabase } from '../supabase';
|
|
||||||
|
|
||||||
interface LocationState {
|
|
||||||
email: string;
|
|
||||||
type?: 'signup' | 'recovery';
|
|
||||||
resent?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VerifyEmailPage: React.FC = () => {
|
|
||||||
const history = useHistory();
|
|
||||||
const location = useLocation<LocationState>();
|
|
||||||
const email = location.state?.email || '';
|
|
||||||
const type = location.state?.type || 'signup';
|
|
||||||
|
|
||||||
const [digits, setDigits] = useState<string[]>(['', '', '', '', '', '']);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [resending, setResending] = useState(false);
|
|
||||||
const [cooldown, setCooldown] = useState(location.state?.resent ? 60 : 0);
|
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
const [messageType, setMessageType] = useState<'error' | 'success'>('error');
|
|
||||||
|
|
||||||
const inputRefs = useRef<HTMLIonInputElement[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (cooldown > 0) {
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
setCooldown((c) => c - 1);
|
|
||||||
}, 1000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}
|
|
||||||
}, [cooldown]);
|
|
||||||
|
|
||||||
const showMessage = (msg: string, type: 'error' | 'success' = 'error') => {
|
|
||||||
setMessage(msg);
|
|
||||||
setMessageType(type);
|
|
||||||
setTimeout(() => setMessage(null), 4000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDigitChange = (index: number, value: string) => {
|
|
||||||
const newDigits = [...digits];
|
|
||||||
// Only take the last character (for overwriting)
|
|
||||||
const val = value.slice(-1);
|
|
||||||
|
|
||||||
if (val && !/^\d+$/.test(val)) return;
|
|
||||||
|
|
||||||
newDigits[index] = val;
|
|
||||||
setDigits(newDigits);
|
|
||||||
|
|
||||||
if (val && index < 5) {
|
|
||||||
inputRefs.current[index + 1]?.setFocus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (index: number, e: any) => {
|
|
||||||
if (e.key === 'Backspace' && !digits[index] && index > 0) {
|
|
||||||
inputRefs.current[index - 1]?.setFocus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVerify = async () => {
|
|
||||||
const code = digits.join('');
|
|
||||||
if (code.length < 6) {
|
|
||||||
showMessage('Please enter the 6-digit code');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.auth.verifyOtp({
|
|
||||||
email,
|
|
||||||
token: code,
|
|
||||||
type: (type === 'recovery' ? 'recovery' : 'signup') as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
if (type === 'recovery') {
|
|
||||||
history.push('/new-password');
|
|
||||||
} else {
|
|
||||||
history.push('/home');
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
showMessage(err.message || 'Invalid or expired code');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResend = async () => {
|
|
||||||
if (cooldown > 0) return;
|
|
||||||
|
|
||||||
setResending(true);
|
|
||||||
try {
|
|
||||||
const { error } = await supabase.auth.resend({
|
|
||||||
type: (type === 'recovery' ? 'recovery' : 'signup') as any,
|
|
||||||
email,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
showMessage('A new code has been sent', 'success');
|
|
||||||
setCooldown(60);
|
|
||||||
} catch (err: any) {
|
|
||||||
showMessage(err.message || 'Failed to resend code');
|
|
||||||
} finally {
|
|
||||||
setResending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonPage>
|
|
||||||
<IonContent
|
|
||||||
fullscreen
|
|
||||||
style={{
|
|
||||||
'--background':
|
|
||||||
'linear-gradient(160deg, rgba(199,210,254,0.52) 0%, #f0f4ff 58%, #f8fafc 100%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="auth-shell">
|
|
||||||
<IonButton
|
|
||||||
fill="clear"
|
|
||||||
color="dark"
|
|
||||||
onClick={() => history.push('/auth')}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 'calc(var(--ion-safe-area-top, 0px) + 8px)',
|
|
||||||
left: '8px',
|
|
||||||
'--padding-start': '8px',
|
|
||||||
'--padding-end': '8px',
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#ffffff',
|
|
||||||
width: '44px',
|
|
||||||
height: '44px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon icon={chevronBackOutline} />
|
|
||||||
</div>
|
|
||||||
</IonButton>
|
|
||||||
|
|
||||||
<div style={{ marginTop: '80px' }}>
|
|
||||||
<h1
|
|
||||||
className="bold"
|
|
||||||
style={{ fontSize: '24px', margin: '0 0 8px 0' }}
|
|
||||||
>
|
|
||||||
Verify your email
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
className="secondary-text"
|
|
||||||
style={{ margin: '0', fontSize: '14px' }}
|
|
||||||
>
|
|
||||||
We've sent a 6-digit code to <span className="bold">{email}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="auth-card">
|
|
||||||
<div
|
|
||||||
style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}
|
|
||||||
>
|
|
||||||
{digits.map((digit, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
width: '44px',
|
|
||||||
height: '52px',
|
|
||||||
background: '#e8edf8',
|
|
||||||
borderRadius: '12px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonInput
|
|
||||||
ref={(el) => {
|
|
||||||
inputRefs.current[i] = el!;
|
|
||||||
}}
|
|
||||||
value={digit}
|
|
||||||
onIonInput={(e) => handleDigitChange(i, e.detail.value!)}
|
|
||||||
onKeyDown={(e) => handleKeyDown(i, e)}
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
fontSize: '22px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#111827',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
messageType === 'error'
|
|
||||||
? 'var(--color-danger-soft)'
|
|
||||||
: 'var(--color-success-soft)',
|
|
||||||
color:
|
|
||||||
messageType === 'error'
|
|
||||||
? 'var(--ion-color-danger-shade)'
|
|
||||||
: 'var(--ion-color-success-shade)',
|
|
||||||
padding: '12px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
fontSize: '13px',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IonButton
|
|
||||||
expand="block"
|
|
||||||
className="bold"
|
|
||||||
style={{
|
|
||||||
height: '52px',
|
|
||||||
'--border-radius': 'var(--radius-pill)',
|
|
||||||
marginTop: '16px',
|
|
||||||
}}
|
|
||||||
disabled={submitting}
|
|
||||||
onClick={handleVerify}
|
|
||||||
>
|
|
||||||
{submitting ? <IonSpinner name="crescent" /> : 'Verify code'}
|
|
||||||
</IonButton>
|
|
||||||
|
|
||||||
<div style={{ textAlign: 'center', marginTop: '8px' }}>
|
|
||||||
<IonText
|
|
||||||
color={cooldown > 0 ? 'medium' : 'primary'}
|
|
||||||
className="semibold"
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
cursor: cooldown > 0 ? 'default' : 'pointer',
|
|
||||||
}}
|
|
||||||
onClick={handleResend}
|
|
||||||
>
|
|
||||||
{resending ? (
|
|
||||||
<IonSpinner name="dots" />
|
|
||||||
) : cooldown > 0 ? (
|
|
||||||
`Resend in ${cooldown}s`
|
|
||||||
) : (
|
|
||||||
'Resend code'
|
|
||||||
)}
|
|
||||||
</IonText>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</IonContent>
|
|
||||||
</IonPage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default VerifyEmailPage;
|
|
||||||
+2
-2
@@ -2,6 +2,6 @@ import { createClient } from '@supabase/supabase-js';
|
|||||||
import type { Database } from './database.types';
|
import type { Database } from './database.types';
|
||||||
|
|
||||||
export const supabase = createClient<Database>(
|
export const supabase = createClient<Database>(
|
||||||
'https://sxdbfylkgcfmsatqipli.supabase.co',
|
'https://zillycfjfbzofltgyjwy.supabase.co',
|
||||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InN4ZGJmeWxrZ2NmbXNhdHFpcGxpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODExMTI5NjcsImV4cCI6MjA5NjY4ODk2N30.8ZvpjRBskCIH3FFnOkWsxZCOwNMX5txinffgXMDinKI',
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InppbGx5Y2ZqZmJ6b2ZsdGd5and5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkxMjYzODQsImV4cCI6MjA5NDcwMjM4NH0.BB4ccBEQI2krc7SseC73234Nfya-92VGcTELNQdzt50',
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
-51
@@ -1,51 +0,0 @@
|
|||||||
// Ambient test globals for optional *.test.* files in the editor / tsc.
|
|
||||||
// No test runner is installed in the template; stubs avoid spurious TS errors.
|
|
||||||
|
|
||||||
interface Matchers<R = void> {
|
|
||||||
toBeDefined(): R;
|
|
||||||
toBe(expected: unknown): R;
|
|
||||||
toEqual(expected: unknown): R;
|
|
||||||
toBeTruthy(): R;
|
|
||||||
toBeFalsy(): R;
|
|
||||||
toBeNull(): R;
|
|
||||||
toContain(item: unknown): R;
|
|
||||||
toHaveLength(length: number): R;
|
|
||||||
toMatch(expected: string | RegExp): R;
|
|
||||||
toThrow(expected?: string | RegExp | Error): R;
|
|
||||||
not: Matchers<R>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Expect {
|
|
||||||
<T = unknown>(actual: T): Matchers;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare const expect: Expect;
|
|
||||||
|
|
||||||
type TestCaseFn = (
|
|
||||||
name: string,
|
|
||||||
fn?: () => void | Promise<void>,
|
|
||||||
timeout?: number,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
declare const test: TestCaseFn;
|
|
||||||
declare const it: TestCaseFn;
|
|
||||||
declare const describe: TestCaseFn;
|
|
||||||
|
|
||||||
declare function beforeEach(fn: () => void | Promise<void>): void;
|
|
||||||
declare function afterEach(fn: () => void | Promise<void>): void;
|
|
||||||
declare function beforeAll(fn: () => void | Promise<void>): void;
|
|
||||||
declare function afterAll(fn: () => void | Promise<void>): void;
|
|
||||||
|
|
||||||
declare module "@testing-library/react" {
|
|
||||||
export function render(
|
|
||||||
ui: unknown,
|
|
||||||
options?: object,
|
|
||||||
): {
|
|
||||||
baseElement: Element;
|
|
||||||
container: Element;
|
|
||||||
unmount(): void;
|
|
||||||
rerender(ui: unknown): void;
|
|
||||||
};
|
|
||||||
export const screen: Record<string, (...args: unknown[]) => Element>;
|
|
||||||
export function cleanup(): void;
|
|
||||||
}
|
|
||||||
+837
-212
File diff suppressed because it is too large
Load Diff
@@ -1,73 +0,0 @@
|
|||||||
/**
|
|
||||||
* Date utilities for ShopKeep.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns today's date in YYYY-MM-DD format (local time).
|
|
||||||
*/
|
|
||||||
export const getTodayISO = (): string => {
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(now.getDate()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a date string (YYYY-MM-DD) or ISO string for display.
|
|
||||||
*/
|
|
||||||
export const formatDisplayDate = (
|
|
||||||
dateStr: string,
|
|
||||||
options: Intl.DateTimeFormatOptions = {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
}
|
|
||||||
): string => {
|
|
||||||
if (!dateStr) return '';
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
if (isNaN(date.getTime())) return dateStr;
|
|
||||||
return new Intl.DateTimeFormat('en-US', options).format(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a relative date string (Today, Yesterday, or formatted date).
|
|
||||||
*/
|
|
||||||
export const formatRelativeDate = (dateStr: string): string => {
|
|
||||||
const today = getTodayISO();
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const dateISO = date.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
if (dateISO === today) return 'Today';
|
|
||||||
|
|
||||||
const yesterday = new Date();
|
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
|
||||||
const yesterdayISO = yesterday.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
if (dateISO === yesterdayISO) return 'Yesterday';
|
|
||||||
|
|
||||||
return formatDisplayDate(dateStr);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Group items by date.
|
|
||||||
*/
|
|
||||||
export const groupItemsByDate = <T extends { date: string }>(
|
|
||||||
items: T[]
|
|
||||||
): { date: string; items: T[] }[] => {
|
|
||||||
const groups: Record<string, T[]> = {};
|
|
||||||
|
|
||||||
items.forEach((item) => {
|
|
||||||
if (!groups[item.date]) {
|
|
||||||
groups[item.date] = [];
|
|
||||||
}
|
|
||||||
groups[item.date].push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.keys(groups)
|
|
||||||
.sort((a, b) => b.localeCompare(a)) // Sort by date descending
|
|
||||||
.map((date) => ({
|
|
||||||
date,
|
|
||||||
items: groups[date],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
/**
|
|
||||||
* Money utilities for sanitizing, parsing, and formatting currency values.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitizes input string to allow only digits and one decimal point.
|
|
||||||
*/
|
|
||||||
export const sanitizeMoneyInput = (value: string): string => {
|
|
||||||
// Remove anything that isn't a digit or a period
|
|
||||||
let sanitized = value.replace(/[^0-9.]/g, '');
|
|
||||||
|
|
||||||
// Ensure only one period exists
|
|
||||||
const parts = sanitized.split('.');
|
|
||||||
if (parts.length > 2) {
|
|
||||||
sanitized = parts[0] + '.' + parts.slice(1).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit to 2 decimal places
|
|
||||||
if (parts.length === 2 && parts[1].length > 2) {
|
|
||||||
sanitized = parts[0] + '.' + parts[1].slice(0, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sanitized;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a sanitized string into a number.
|
|
||||||
*/
|
|
||||||
export const parseMoney = (value: string): number => {
|
|
||||||
const parsed = parseFloat(value);
|
|
||||||
return isNaN(parsed) ? 0 : parsed;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a number as currency.
|
|
||||||
*/
|
|
||||||
export const formatMoney = (
|
|
||||||
amount: number,
|
|
||||||
currency: string = 'USD',
|
|
||||||
locale: string = 'en-US'
|
|
||||||
): string => {
|
|
||||||
return new Intl.NumberFormat(locale, {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency,
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the currency symbol for a given currency code.
|
|
||||||
*/
|
|
||||||
export const getCurrencySymbol = (
|
|
||||||
currency: string = 'USD',
|
|
||||||
locale: string = 'en-US'
|
|
||||||
): string => {
|
|
||||||
return (
|
|
||||||
new Intl.NumberFormat(locale, {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency,
|
|
||||||
})
|
|
||||||
.formatToParts(0)
|
|
||||||
.find((part) => part.type === 'currency')?.value || '$'
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { StatusBar, Style } from '@capacitor/status-bar';
|
import { StatusBar, Style } from '@capacitor/status-bar';
|
||||||
export { Style };
|
export { Style };
|
||||||
``
|
|
||||||
const _origin = (() => {
|
const _origin = (() => {
|
||||||
try { if (document.referrer) return new URL(document.referrer).origin; } catch {}
|
try { if (document.referrer) return new URL(document.referrer).origin; } catch {}
|
||||||
return window.location.origin;
|
return window.location.origin;
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": false,
|
"esModuleInterop": false,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"strict": false,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "Node",
|
||||||
|
|||||||
+2
-6
@@ -1,6 +1,3 @@
|
|||||||
// NOTE: @ionic/react and @ionic/react-router are pinned to 8.8.7 in package.json.
|
|
||||||
// 8.8.8 introduced a regression that breaks IonRouterOutlet page transition animations.
|
|
||||||
// Do not upgrade until the upstream fix is confirmed.
|
|
||||||
import { defineConfig, type Plugin } from 'vite';
|
import { defineConfig, type Plugin } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
@@ -8,12 +5,11 @@ const injectRouterBasename: Plugin = {
|
|||||||
name: 'inject-router-basename',
|
name: 'inject-router-basename',
|
||||||
enforce: 'pre',
|
enforce: 'pre',
|
||||||
transform(code, id) {
|
transform(code, id) {
|
||||||
if (!/\.[jt]sx$/.test(id)) return null;
|
if (!/\.[jt]sx$/.test(id)) return;
|
||||||
const next = code.replace(
|
return code.replace(
|
||||||
/<IonReactRouter(?![^>]*basename)([^>]*)>/g,
|
/<IonReactRouter(?![^>]*basename)([^>]*)>/g,
|
||||||
'<IonReactRouter$1 basename={import.meta.env.BASE_URL}>',
|
'<IonReactRouter$1 basename={import.meta.env.BASE_URL}>',
|
||||||
);
|
);
|
||||||
return next === code ? null : next;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user