Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c279e86750 |
@@ -4,7 +4,7 @@ android {
|
||||
namespace = "io.ionic.starter"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.kumusha.app"
|
||||
applicationId "com.habitmode.llemba.com"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -21,12 +22,6 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="com.kumusha.app" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">kumusha</string>
|
||||
<string name="title_activity_main">kumusha</string>
|
||||
<string name="app_name">app</string>
|
||||
<string name="title_activity_main">app</string>
|
||||
<string name="package_name">io.ionic.starter</string>
|
||||
<string name="custom_url_scheme">io.ionic.starter</string>
|
||||
</resources>
|
||||
|
||||
@@ -15,4 +15,5 @@ ext {
|
||||
cordovaAndroidVersion = '14.0.1'
|
||||
rgcfaIncludeGoogle = true
|
||||
androidxCredentialsVersion = '1.3.0'
|
||||
javaVersion = JavaVersion.VERSION_17
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.kumusha.app',
|
||||
appName: 'kumusha',
|
||||
appId: 'com.habitmode.llemba.com',
|
||||
appName: 'HabitMode',
|
||||
webDir: 'dist',
|
||||
plugins: {
|
||||
FirebaseAuthentication: {
|
||||
skipNativeAuth: false,
|
||||
providers: [],
|
||||
},
|
||||
PushNotifications: {
|
||||
presentationOptions: ['badge', 'sound', 'banner'],
|
||||
providers: ["google.com", "apple.com"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,6 +55,13 @@ workflows:
|
||||
if [ -n "$CM_DEBUG_KEYSTORE" ]; then
|
||||
echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12
|
||||
fi
|
||||
- name: Check native credentials
|
||||
script: |
|
||||
if [ -n "$VITE_GOOGLE_MAPS_KEY" ]; then
|
||||
echo "VITE_GOOGLE_MAPS_KEY: set (length=${#VITE_GOOGLE_MAPS_KEY}, ends …${VITE_GOOGLE_MAPS_KEY: -4})"
|
||||
else
|
||||
echo "WARNING: VITE_GOOGLE_MAPS_KEY not set — Google Maps will not render"
|
||||
fi
|
||||
- name: Build debug APK
|
||||
script: |
|
||||
cd android
|
||||
@@ -68,6 +75,21 @@ workflows:
|
||||
else
|
||||
./gradlew assembleDebug --no-daemon
|
||||
fi
|
||||
- name: Verify manifest substitution
|
||||
script: |
|
||||
MANIFEST="android/app/build/intermediates/merged_manifests/debug/AndroidManifest.xml"
|
||||
if [ -f "$MANIFEST" ]; then
|
||||
VAL=$(grep -A1 'geo.API_KEY' "$MANIFEST" | grep -o 'android:value="[^"]*"' | cut -d'"' -f2)
|
||||
if [ -z "$VAL" ]; then
|
||||
echo "WARNING: geo.API_KEY meta-data not found in merged manifest"
|
||||
elif [ "$VAL" = '${googleMapsApiKey}' ]; then
|
||||
echo "WARNING: geo.API_KEY placeholder was not substituted — key missing from build env"
|
||||
else
|
||||
echo "geo.API_KEY substituted OK (ends …${VAL: -4})"
|
||||
fi
|
||||
else
|
||||
echo "Merged manifest not found at $MANIFEST"
|
||||
fi
|
||||
artifacts:
|
||||
- android/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
|
||||
@@ -308,7 +308,7 @@
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kumusha.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -330,7 +330,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kumusha.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
<key>aps-environment</key>
|
||||
<string>production</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -13,38 +13,21 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
return true
|
||||
}
|
||||
|
||||
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 applicationWillResignActive(_ 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 applicationDidEnterBackground(_ 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 applicationWillEnterForeground(_ 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 applicationDidBecomeActive(_ application: UIApplication) {}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
func applicationWillTerminate(_ application: UIApplication) {}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
// Called when the app was launched with a url. Feel free to add additional processing here,
|
||||
// but if you want the App API to support tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||
// Called when the app was launched with an activity, including Universal Links.
|
||||
// Feel free to add additional processing here, but if you want the App API to support
|
||||
// tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
@@ -62,5 +45,4 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>kumusha</string>
|
||||
<string>HabitMode</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -47,18 +47,12 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Kumusha needs access to your photo library so you can add profile and recipient photos.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Kumusha needs camera access so you can add profile and recipient photos.</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.kumusha.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>com.kumusha.app</string>
|
||||
<string>com.googleusercontent.apps.1097913965629-pf48952i0uv9shgvie3dq11s18sd0t2p</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
@@ -14,24 +14,19 @@
|
||||
"@capacitor/android": "8.3.4",
|
||||
"@capacitor/app": "8.1.0",
|
||||
"@capacitor/core": "8.3.4",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/google-maps": "^8.0.0",
|
||||
"@capacitor/haptics": "8.0.2",
|
||||
"@capacitor/ios": "8.3.4",
|
||||
"@capacitor/keyboard": "8.0.3",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"@capacitor/push-notifications": "^8.1.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/status-bar": "8.0.2",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@ionic/pwa-elements": "^3.0.0",
|
||||
"@ionic/react": "8.8.7",
|
||||
"@ionic/react-router": "8.8.7",
|
||||
"@supabase/supabase-js": "^2.108.1",
|
||||
"@ionic/pwa-elements": "^3.4.0",
|
||||
"@ionic/react": "^8.5.0",
|
||||
"@ionic/react-router": "^8.5.0",
|
||||
"@supabase/supabase-js": "^2.0.0",
|
||||
"firebase": "^11.0.0",
|
||||
"ionicons": "^7.4.0",
|
||||
"katex": "^0.16.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.54.0",
|
||||
|
||||
@@ -1,29 +1,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Redirect, Route, useHistory, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
IonApp,
|
||||
IonIcon,
|
||||
IonLabel,
|
||||
IonRouterOutlet,
|
||||
IonTabBar,
|
||||
IonTabButton,
|
||||
IonTabs,
|
||||
} from '@ionic/react';
|
||||
import React from 'react';
|
||||
import { Redirect, Route } from 'react-router-dom';
|
||||
import { IonApp, IonRouterOutlet } from '@ionic/react';
|
||||
import { IonReactRouter } from '@ionic/react-router';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { PushNotifications } from '@capacitor/push-notifications';
|
||||
import type {
|
||||
ActionPerformed,
|
||||
PushNotificationSchema,
|
||||
} from '@capacitor/push-notifications';
|
||||
import { supabase } from './supabase';
|
||||
import {
|
||||
heart,
|
||||
heartOutline,
|
||||
listOutline,
|
||||
peopleOutline,
|
||||
personOutline,
|
||||
} from 'ionicons/icons';
|
||||
|
||||
import '@ionic/react/css/core.css';
|
||||
import '@ionic/react/css/normalize.css';
|
||||
@@ -35,283 +13,28 @@ import '@ionic/react/css/text-alignment.css';
|
||||
import '@ionic/react/css/text-transformation.css';
|
||||
import '@ionic/react/css/flex-utils.css';
|
||||
import '@ionic/react/css/display.css';
|
||||
import '@ionic/react/css/palettes/dark.system.css';
|
||||
|
||||
import './theme/variables.css';
|
||||
|
||||
import { setStatusBarStyle, Style } from './utils/statusBar';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
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';
|
||||
|
||||
// Auth Pages
|
||||
import AuthPage from './pages/AuthPage';
|
||||
import VerifyEmailPage from './pages/VerifyEmailPage';
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage';
|
||||
import VerifyResetPage from './pages/VerifyResetPage';
|
||||
import SetupProfilePage from './pages/SetupProfilePage';
|
||||
|
||||
// Main App Pages
|
||||
import HomePage from './pages/HomePage';
|
||||
import RecipientsPage from './pages/RecipientsPage';
|
||||
import RecipientFormPage from './pages/RecipientFormPage';
|
||||
import RecipientDetailPage from './pages/RecipientDetailPage';
|
||||
import SupportFlowPage from './pages/SupportFlowPage';
|
||||
import OrderDetailPage from './pages/OrderDetailPage';
|
||||
import VoucherDetailPage from './pages/VoucherDetailPage';
|
||||
import ActivityPage from './pages/ActivityPage';
|
||||
import NotificationsPage from './pages/NotificationsPage';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import EditProfilePage from './pages/EditProfilePage';
|
||||
|
||||
const SetupProfileRoute: React.FC = () => {
|
||||
const { user, profileStatus } = useAuth();
|
||||
return (
|
||||
<Route
|
||||
path="/setup-profile"
|
||||
render={() => {
|
||||
if (!user) return <Redirect to="/auth" />;
|
||||
if (profileStatus === 'loading') return null;
|
||||
if (profileStatus === 'loaded') return <Redirect to="/home" />;
|
||||
return <SetupProfilePage />;
|
||||
}}
|
||||
exact
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SendSupportFab: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const hiddenPaths = [
|
||||
'/auth',
|
||||
'/verify-email',
|
||||
'/forgot-password',
|
||||
'/verify-reset',
|
||||
'/setup-profile',
|
||||
'/support/new',
|
||||
];
|
||||
const showOnPaths = ['/home', '/recipients', '/activity', '/profile'];
|
||||
const isVisibleRoute =
|
||||
showOnPaths.includes(location.pathname) ||
|
||||
/^\/recipients\/[0-9a-fA-F-]{36}$/.test(location.pathname);
|
||||
|
||||
if (!isVisibleRoute || hiddenPaths.includes(location.pathname)) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="app-send-support-fab"
|
||||
aria-label="Send support"
|
||||
onClick={() => history.push('/support/new')}
|
||||
>
|
||||
<IonIcon icon={heart} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const PushNotificationHandler: React.FC = () => {
|
||||
const { user, profile } = useAuth();
|
||||
const history = useHistory();
|
||||
|
||||
useEffect(() => {
|
||||
if (!Capacitor.isNativePlatform() || !user || !profile) return;
|
||||
|
||||
const setupPush = async () => {
|
||||
const handleNotificationTap = (notification: PushNotificationSchema) => {
|
||||
const { orderId, voucherId, screen } = notification.data || {};
|
||||
if (voucherId) {
|
||||
history.push(`/voucher/${voucherId}`, { parentRoot: '/home' });
|
||||
} else if (orderId) {
|
||||
history.push(`/orders/${orderId}`, { parentRoot: '/home' });
|
||||
} else if (screen) {
|
||||
history.push(screen, { parentRoot: '/home' });
|
||||
}
|
||||
};
|
||||
|
||||
// Capacitor automatically triggers pushNotificationActionPerformed
|
||||
// for the notification that launched the app once the listener is attached.
|
||||
|
||||
// Check if user has explicitly enabled push notifications
|
||||
if (!profile.notification_push_enabled) return;
|
||||
|
||||
const status = await PushNotifications.checkPermissions();
|
||||
if (status.receive === 'granted') {
|
||||
await PushNotifications.register();
|
||||
} else if (status.receive === 'prompt') {
|
||||
const result = await PushNotifications.requestPermissions();
|
||||
if (result.receive === 'granted') {
|
||||
await PushNotifications.register();
|
||||
}
|
||||
}
|
||||
|
||||
PushNotifications.addListener('registration', async (token) => {
|
||||
await supabase
|
||||
.from('profiles')
|
||||
.update({ fcm_token: token.value })
|
||||
.eq('id', user.id);
|
||||
});
|
||||
|
||||
PushNotifications.addListener('registrationError', (err) => {
|
||||
console.error('Push registration error:', err);
|
||||
});
|
||||
|
||||
PushNotifications.addListener('pushNotificationReceived', () => {
|
||||
// Handled natively via presentationOptions banner
|
||||
});
|
||||
|
||||
PushNotifications.addListener(
|
||||
'pushNotificationActionPerformed',
|
||||
(action: ActionPerformed) => {
|
||||
handleNotificationTap(action.notification);
|
||||
}
|
||||
);
|
||||
|
||||
await PushNotifications.removeAllDeliveredNotifications();
|
||||
};
|
||||
|
||||
setupPush();
|
||||
|
||||
return () => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
PushNotifications.removeAllListeners();
|
||||
}
|
||||
};
|
||||
}, [user, profile, history]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const RootRedirect: React.FC = () => {
|
||||
const { user, profileStatus } = useAuth();
|
||||
|
||||
if (!user) return <Redirect to="/auth" />;
|
||||
if (profileStatus === 'loading') return null;
|
||||
if (profileStatus === 'missing') {
|
||||
return <Redirect to="/setup-profile" />;
|
||||
}
|
||||
return <Redirect to="/home" />;
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
useEffect(() => {
|
||||
setStatusBarStyle(Style.Light);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<IonApp>
|
||||
<AuthProvider>
|
||||
<IonReactRouter>
|
||||
<IonRouterOutlet>
|
||||
{/* Auth Routes */}
|
||||
<Route path="/auth" component={AuthPage} exact />
|
||||
<Route path="/verify-email" component={VerifyEmailPage} exact />
|
||||
<Route
|
||||
path="/forgot-password"
|
||||
component={ForgotPasswordPage}
|
||||
exact
|
||||
/>
|
||||
<Route path="/verify-reset" component={VerifyResetPage} exact />
|
||||
|
||||
{/* Setup Profile Route - Requires authenticated user but not necessarily complete profile */}
|
||||
<SetupProfileRoute />
|
||||
|
||||
{/* Top-level Protected Routes (no tab bar) */}
|
||||
<ProtectedRoute
|
||||
path="/support/new"
|
||||
component={SupportFlowPage}
|
||||
exact
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/orders/:id([0-9a-fA-F-]{36})"
|
||||
component={OrderDetailPage}
|
||||
exact
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/recipients/new"
|
||||
component={RecipientFormPage}
|
||||
exact
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/recipients/:id([0-9a-fA-F-]{36})"
|
||||
component={RecipientDetailPage}
|
||||
exact
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/recipients/:id([0-9a-fA-F-]{36})/edit"
|
||||
component={RecipientFormPage}
|
||||
exact
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/profile/edit"
|
||||
component={EditProfilePage}
|
||||
exact
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/notifications"
|
||||
component={NotificationsPage}
|
||||
exact
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/voucher/:id([0-9a-fA-F-]{36})"
|
||||
component={VoucherDetailPage}
|
||||
exact
|
||||
/>
|
||||
|
||||
{/* Tab Routes */}
|
||||
<ProtectedRoute
|
||||
path={['/home', '/recipients', '/activity', '/profile']}
|
||||
exact
|
||||
component={() => (
|
||||
<IonTabs>
|
||||
<IonRouterOutlet>
|
||||
<Route path="/home" component={HomePage} exact />
|
||||
<Route
|
||||
path="/recipients"
|
||||
component={RecipientsPage}
|
||||
exact
|
||||
/>
|
||||
<Route path="/activity" component={ActivityPage} exact />
|
||||
<Route path="/profile" component={ProfilePage} exact />
|
||||
</IonRouterOutlet>
|
||||
|
||||
<IonTabBar slot="bottom" className="app-tab-bar">
|
||||
<IonTabButton tab="home" href="/home">
|
||||
<IonIcon icon={heartOutline} />
|
||||
<IonLabel>Care</IonLabel>
|
||||
</IonTabButton>
|
||||
<IonTabButton tab="recipients" href="/recipients">
|
||||
<IonIcon icon={peopleOutline} />
|
||||
<IonLabel>Recipients</IonLabel>
|
||||
</IonTabButton>
|
||||
<IonTabButton disabled tab="send-support-fab" />
|
||||
<IonTabButton tab="activity" href="/activity">
|
||||
<IonIcon icon={listOutline} />
|
||||
<IonLabel>Activity</IonLabel>
|
||||
</IonTabButton>
|
||||
<IonTabButton tab="profile" href="/profile">
|
||||
<IonIcon icon={personOutline} />
|
||||
<IonLabel>Profile</IonLabel>
|
||||
</IonTabButton>
|
||||
</IonTabBar>
|
||||
</IonTabs>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Orders Redirect */}
|
||||
<Route
|
||||
exact
|
||||
path="/orders"
|
||||
render={() => <Redirect to="/activity" />}
|
||||
/>
|
||||
|
||||
{/* Root Redirect */}
|
||||
<Route exact path="/" component={RootRedirect} />
|
||||
</IonRouterOutlet>
|
||||
<SendSupportFab />
|
||||
<PushNotificationHandler />
|
||||
</IonReactRouter>
|
||||
</AuthProvider>
|
||||
</IonApp>
|
||||
);
|
||||
};
|
||||
const App = (): any => (
|
||||
<IonApp>
|
||||
<IonReactRouter>
|
||||
<IonRouterOutlet>
|
||||
<Route exact path="/auth" component={Auth} />
|
||||
<Route exact path="/home" component={Home} />
|
||||
<Route exact path="/stats" component={Stats} />
|
||||
<Route exact path="/habit/:id" component={HabitDetail} />
|
||||
<Route exact path="/account" component={Account} />
|
||||
<Route exact path="/" render={() => <Redirect to="/home" />} />
|
||||
</IonRouterOutlet>
|
||||
</IonReactRouter>
|
||||
</IonApp>
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
||||
|
Before Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 771 KiB |
|
After Width: | Height: | Size: 752 KiB |
|
Before Width: | Height: | Size: 101 KiB |
@@ -1,155 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
import {
|
||||
medkitOutline,
|
||||
flashOutline,
|
||||
phonePortraitOutline,
|
||||
chevronForwardOutline,
|
||||
} from 'ionicons/icons';
|
||||
import momImage from '../assets/mom.jpg';
|
||||
import dadImage from '../assets/dad.jpg';
|
||||
import basketIcon from '../assets/basket.png';
|
||||
|
||||
interface ActivityListItemProps {
|
||||
id: string;
|
||||
eventType: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
amount?: number;
|
||||
statusText?: string;
|
||||
statusTone?: 'success' | 'warning' | 'partial';
|
||||
avatarLabel?: string;
|
||||
avatarTone?: string;
|
||||
avatarImage?: string;
|
||||
onClick?: (id: string) => void;
|
||||
onStatusClick?: () => void;
|
||||
}
|
||||
|
||||
const ActivityListItem: React.FC<ActivityListItemProps> = ({
|
||||
id,
|
||||
eventType,
|
||||
title,
|
||||
subtitle,
|
||||
amount,
|
||||
statusText,
|
||||
statusTone = 'warning',
|
||||
avatarLabel,
|
||||
avatarTone = 'brand',
|
||||
avatarImage,
|
||||
onClick,
|
||||
onStatusClick,
|
||||
}) => {
|
||||
const getIconAndColor = () => {
|
||||
if (
|
||||
eventType.includes('grocery') ||
|
||||
title.toLowerCase().includes('grocer')
|
||||
) {
|
||||
return {
|
||||
iconSrc: basketIcon,
|
||||
badgeClass: 'grocery',
|
||||
};
|
||||
}
|
||||
if (
|
||||
eventType.includes('medication') ||
|
||||
title.toLowerCase().includes('medic')
|
||||
) {
|
||||
return {
|
||||
icon: medkitOutline,
|
||||
badgeClass: 'medication',
|
||||
};
|
||||
}
|
||||
if (
|
||||
eventType.includes('airtime') ||
|
||||
title.toLowerCase().includes('airtime')
|
||||
) {
|
||||
return {
|
||||
icon: phonePortraitOutline,
|
||||
badgeClass: 'airtime',
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: flashOutline,
|
||||
badgeClass: 'electricity',
|
||||
};
|
||||
};
|
||||
|
||||
const { icon, iconSrc, badgeClass } = getIconAndColor();
|
||||
|
||||
const avatarSearchText =
|
||||
`${title} ${subtitle} ${avatarLabel ?? ''}`.toLowerCase();
|
||||
const resolvedAvatar =
|
||||
avatarImage ??
|
||||
(avatarSearchText.includes('mum') || avatarSearchText.includes('mom')
|
||||
? momImage
|
||||
: avatarSearchText.includes('dad') || avatarSearchText.includes('father')
|
||||
? dadImage
|
||||
: undefined);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick && onClick(id)}
|
||||
className="activity-feed-item"
|
||||
disabled={!onClick}
|
||||
>
|
||||
<div className="activity-feed-avatar-wrap">
|
||||
<div
|
||||
className={`activity-feed-avatar activity-feed-avatar-${avatarTone}`}
|
||||
>
|
||||
{resolvedAvatar ? (
|
||||
<img
|
||||
src={resolvedAvatar}
|
||||
alt=""
|
||||
className="activity-feed-avatar-image"
|
||||
/>
|
||||
) : (
|
||||
<span>{avatarLabel ?? 'KU'}</span>
|
||||
)}
|
||||
<div
|
||||
className={`activity-feed-avatar-badge activity-feed-avatar-badge-${badgeClass}`}
|
||||
>
|
||||
{iconSrc ? (
|
||||
<img src={iconSrc} alt="" className="activity-feed-badge-image" />
|
||||
) : (
|
||||
<IonIcon icon={icon} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="activity-feed-main">
|
||||
<div className="activity-feed-row">
|
||||
<div className="activity-feed-copy">
|
||||
<p className="activity-feed-title">{title}</p>
|
||||
<p className="activity-feed-subtitle">{subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="activity-feed-right">
|
||||
{amount !== undefined && (
|
||||
<p className="activity-feed-amount">${amount.toFixed(2)}</p>
|
||||
)}
|
||||
{statusText && (
|
||||
<button
|
||||
type="button"
|
||||
className={`activity-feed-status activity-feed-status-${statusTone}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (onStatusClick) {
|
||||
onStatusClick();
|
||||
return;
|
||||
}
|
||||
onClick?.(id);
|
||||
}}
|
||||
>
|
||||
<span>{statusText}</span>
|
||||
<IonIcon icon={chevronForwardOutline} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityListItem;
|
||||
@@ -1,116 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { eyeOffOutline, eyeOutline } from 'ionicons/icons';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
|
||||
interface AuthFormFieldsProps {
|
||||
mode: 'login' | 'register';
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword?: string;
|
||||
onEmailChange: (val: string) => void;
|
||||
onPasswordChange: (val: string) => void;
|
||||
onConfirmPasswordChange?: (val: string) => void;
|
||||
errors?: { [key: string]: string };
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AuthFormFields: React.FC<AuthFormFieldsProps> = ({
|
||||
mode,
|
||||
email,
|
||||
password,
|
||||
confirmPassword,
|
||||
onEmailChange,
|
||||
onPasswordChange,
|
||||
onConfirmPasswordChange,
|
||||
errors = {},
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="auth-form-fields">
|
||||
<div className="field-group">
|
||||
<label className="auth-field-label">Email address</label>
|
||||
<input
|
||||
className={`auth-text-input ${errors.email ? 'has-error' : ''}`}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => onEmailChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
{errors.email ? (
|
||||
<p className="auth-field-error">{errors.email}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="field-group">
|
||||
<label className="auth-field-label">Password</label>
|
||||
<div className="auth-password-wrap">
|
||||
<input
|
||||
className={`auth-text-input ${errors.password ? 'has-error' : ''}`}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => onPasswordChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder="Enter your password"
|
||||
autoComplete={
|
||||
mode === 'login' ? 'current-password' : 'new-password'
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="auth-password-toggle"
|
||||
onClick={() => setShowPassword((value) => !value)}
|
||||
disabled={disabled}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
<IonIcon icon={showPassword ? eyeOffOutline : eyeOutline} />
|
||||
</button>
|
||||
</div>
|
||||
{errors.password ? (
|
||||
<p className="auth-field-error">{errors.password}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mode === 'register' && onConfirmPasswordChange && (
|
||||
<div className="field-group">
|
||||
<label className="auth-field-label">Confirm password</label>
|
||||
<div className="auth-password-wrap">
|
||||
<input
|
||||
className={`auth-text-input ${errors.confirmPassword ? 'has-error' : ''}`}
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => onConfirmPasswordChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder="Re-enter your password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="auth-password-toggle"
|
||||
onClick={() => setShowConfirmPassword((value) => !value)}
|
||||
disabled={disabled}
|
||||
aria-label={
|
||||
showConfirmPassword
|
||||
? 'Hide confirm password'
|
||||
: 'Show confirm password'
|
||||
}
|
||||
>
|
||||
<IonIcon
|
||||
icon={showConfirmPassword ? eyeOffOutline : eyeOutline}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword ? (
|
||||
<p className="auth-field-error">{errors.confirmPassword}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthFormFields;
|
||||
@@ -1,121 +0,0 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
import { camera, personOutline } from 'ionicons/icons';
|
||||
|
||||
interface AvatarPickerProps {
|
||||
previewUrl: string | null;
|
||||
onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
initials?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AvatarPicker: React.FC<AvatarPickerProps> = ({
|
||||
previewUrl,
|
||||
onFileChange,
|
||||
initials,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!disabled) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="avatar-picker-container"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
margin: '8px 0 4px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={previewUrl ? 'Change photo' : 'Add photo'}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '96px',
|
||||
height: '96px',
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="avatar-preview-circle"
|
||||
style={{
|
||||
width: '88px',
|
||||
height: '88px',
|
||||
margin: '0 auto',
|
||||
borderRadius: '24px',
|
||||
backgroundColor: previewUrl
|
||||
? 'transparent'
|
||||
: 'rgba(109,40,217,0.10)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Avatar preview"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : initials ? (
|
||||
<span
|
||||
style={{ fontSize: '28px', fontWeight: '700', color: '#6d28d9' }}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
) : (
|
||||
<IonIcon
|
||||
icon={personOutline}
|
||||
style={{ fontSize: '32px', color: '#6d28d9' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '0px',
|
||||
bottom: '0px',
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '12px',
|
||||
background: '#ffffff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 8px 16px rgba(109, 40, 217, 0.15)',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={camera}
|
||||
style={{ fontSize: '20px', color: '#6d28d9' }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={onFileChange}
|
||||
disabled={disabled}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvatarPicker;
|
||||
@@ -1,88 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
import {
|
||||
chevronForwardOutline,
|
||||
timeOutline,
|
||||
flashOutline,
|
||||
wifiOutline,
|
||||
medkitOutline,
|
||||
} from 'ionicons/icons';
|
||||
|
||||
interface CareAlertPillProps {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
severity: 'warning' | 'neutral';
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
const CareAlertPill: React.FC<CareAlertPillProps> = ({
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
severity,
|
||||
onClick,
|
||||
}) => {
|
||||
const getIconForType = () => {
|
||||
switch (type) {
|
||||
case 'electricity_low':
|
||||
return flashOutline;
|
||||
case 'airtime_expiring':
|
||||
return wifiOutline;
|
||||
case 'medication_due':
|
||||
return medkitOutline;
|
||||
default:
|
||||
return timeOutline;
|
||||
}
|
||||
};
|
||||
|
||||
const getColors = () => {
|
||||
if (severity === 'warning') {
|
||||
return { bg: 'rgba(245,158,11,0.08)', icon: '#f59e0b', text: '#111827' };
|
||||
}
|
||||
return { bg: '#ffffff', icon: '#6d28d9', text: '#111827' };
|
||||
};
|
||||
|
||||
const colors = getColors();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onClick(id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
backgroundColor: colors.bg,
|
||||
borderRadius: '24px',
|
||||
padding: '14px 16px',
|
||||
border: 'none',
|
||||
flexShrink: 0,
|
||||
maxWidth: '280px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={getIconForType()}
|
||||
style={{ fontSize: '20px', color: colors.icon, flexShrink: 0 }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<IonIcon
|
||||
icon={chevronForwardOutline}
|
||||
style={{ fontSize: '16px', color: '#9ca3af', marginLeft: 'auto' }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default CareAlertPill;
|
||||
@@ -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,246 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonSkeletonText } from '@ionic/react';
|
||||
|
||||
const DashboardSkeleton: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: '0' }}>
|
||||
{/* Top Greeting Row */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 20px 12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '48px', height: '48px', borderRadius: '50%' }}
|
||||
/>
|
||||
<div>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '120px',
|
||||
height: '18px',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '80px', height: '14px', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loved Ones Carousel */}
|
||||
<div
|
||||
style={{
|
||||
padding: '0 20px 6px',
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '16px',
|
||||
width: '292px',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '14px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '48px', height: '48px', borderRadius: '16px' }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '100px',
|
||||
height: '16px',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '80px', height: '12px', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '100%', height: '48px', borderRadius: '12px' }}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '48px',
|
||||
borderRadius: '999px',
|
||||
marginTop: 'auto',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '16px',
|
||||
width: '292px',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '14px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '48px', height: '48px', borderRadius: '16px' }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '100px',
|
||||
height: '16px',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '80px', height: '12px', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '100%', height: '48px', borderRadius: '12px' }}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '48px',
|
||||
borderRadius: '999px',
|
||||
marginTop: 'auto',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div style={{ padding: '0 20px', margin: '20px 0' }}>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '120px',
|
||||
height: '16px',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '44px', height: '44px', borderRadius: '12px' }}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '60px', height: '14px', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity List */}
|
||||
<div style={{ padding: '0 20px', margin: '20px 0' }}>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '100px',
|
||||
height: '16px',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '8px 0',
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '14px 16px',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '40px', height: '40px', borderRadius: '12px' }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '140px',
|
||||
height: '14px',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '100px',
|
||||
height: '12px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardSkeleton;
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { IonButton, IonIcon } from '@ionic/react';
|
||||
import { sparklesOutline } from 'ionicons/icons';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
message: string;
|
||||
actionLabel: string;
|
||||
onAction: () => void;
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
title,
|
||||
message,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}) => {
|
||||
return (
|
||||
<div className="empty-state-card home-empty-state-card">
|
||||
<div className="empty-state-icon">
|
||||
<IonIcon icon={sparklesOutline} />
|
||||
</div>
|
||||
<h2>{title}</h2>
|
||||
<p>{message}</p>
|
||||
<IonButton className="home-empty-state-button" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</IonButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyState;
|
||||
@@ -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,86 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
import { searchOutline, optionsOutline } from 'ionicons/icons';
|
||||
|
||||
interface ListSearchRowProps {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
placeholder?: string;
|
||||
onFilterClick?: () => void;
|
||||
showFilter?: boolean;
|
||||
}
|
||||
|
||||
const ListSearchRow: React.FC<ListSearchRowProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search...',
|
||||
onFilterClick,
|
||||
showFilter = false,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
margin: '0 20px 12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '16px',
|
||||
padding: '0 16px',
|
||||
height: '48px',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={searchOutline}
|
||||
style={{ fontSize: '20px', color: '#9ca3af', flexShrink: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
padding: '0 12px',
|
||||
fontSize: '15px',
|
||||
color: '#111827',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showFilter && (
|
||||
<button
|
||||
onClick={onFilterClick}
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '16px',
|
||||
backgroundColor: '#ffffff',
|
||||
border: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={optionsOutline}
|
||||
style={{ fontSize: '20px', color: '#6d28d9' }}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListSearchRow;
|
||||
@@ -1,210 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
import {
|
||||
chevronForwardOutline,
|
||||
checkmarkCircleOutline,
|
||||
timeOutline,
|
||||
closeCircleOutline,
|
||||
} from 'ionicons/icons';
|
||||
|
||||
export interface OrderSummary {
|
||||
id: string;
|
||||
recipientName: string;
|
||||
serviceType: string;
|
||||
merchantName?: string;
|
||||
amount: number;
|
||||
amountLabel?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
interface OrderSummaryCardProps {
|
||||
order: OrderSummary;
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
const OrderSummaryCard: React.FC<OrderSummaryCardProps> = ({
|
||||
order,
|
||||
onClick,
|
||||
}) => {
|
||||
const getStatusDisplay = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return {
|
||||
text: 'Paid',
|
||||
color: '#3b82f6',
|
||||
bg: 'rgba(59,130,246,0.1)',
|
||||
icon: checkmarkCircleOutline,
|
||||
};
|
||||
case 'ready_for_redemption':
|
||||
return {
|
||||
text: 'Ready',
|
||||
color: '#16a34a',
|
||||
bg: 'rgba(22,163,74,0.1)',
|
||||
icon: checkmarkCircleOutline,
|
||||
};
|
||||
case 'redeemed':
|
||||
return {
|
||||
text: 'Redeemed',
|
||||
color: '#16a34a',
|
||||
bg: 'rgba(22,163,74,0.1)',
|
||||
icon: checkmarkCircleOutline,
|
||||
};
|
||||
case 'delivered':
|
||||
return {
|
||||
text: 'Delivered',
|
||||
color: '#16a34a',
|
||||
bg: 'rgba(22,163,74,0.1)',
|
||||
icon: checkmarkCircleOutline,
|
||||
};
|
||||
case 'pending_payment':
|
||||
return {
|
||||
text: 'Pending',
|
||||
color: '#f59e0b',
|
||||
bg: 'rgba(245,158,11,0.1)',
|
||||
icon: timeOutline,
|
||||
};
|
||||
case 'failed':
|
||||
case 'cancelled':
|
||||
return {
|
||||
text: 'Failed',
|
||||
color: '#ef4444',
|
||||
bg: 'rgba(239,68,68,0.1)',
|
||||
icon: closeCircleOutline,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
text: status,
|
||||
color: '#6b7280',
|
||||
bg: '#f3f4f6',
|
||||
icon: timeOutline,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const statusDisplay = getStatusDisplay(order.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick(order.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '16px',
|
||||
margin: '0 20px 12px',
|
||||
gap: '14px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '16px',
|
||||
backgroundColor: 'rgba(109,40,217,0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{order.avatarUrl ? (
|
||||
<img
|
||||
src={order.avatarUrl}
|
||||
alt={order.recipientName}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{ fontSize: '18px', fontWeight: '700', color: '#6d28d9' }}
|
||||
>
|
||||
{order.recipientName.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{order.serviceType} for {order.recipientName}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '400',
|
||||
color: '#6b7280',
|
||||
marginTop: '2px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{order.merchantName || 'Service provider'}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '400',
|
||||
color: '#9ca3af',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{new Date(order.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '15px', fontWeight: '700', color: '#111827' }}>
|
||||
{order.amountLabel ?? `${order.amount.toFixed(2)}`}
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
backgroundColor: statusDisplay.bg,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '999px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: '700',
|
||||
color: statusDisplay.color,
|
||||
}}
|
||||
>
|
||||
{statusDisplay.text}
|
||||
</span>
|
||||
<IonIcon
|
||||
icon={statusDisplay.icon}
|
||||
style={{ fontSize: '12px', color: statusDisplay.color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<IonIcon
|
||||
icon={chevronForwardOutline}
|
||||
style={{ fontSize: '16px', color: '#9ca3af', marginLeft: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderSummaryCard;
|
||||
@@ -1,82 +0,0 @@
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
interface OtpInputSlotsProps {
|
||||
value: string[];
|
||||
onChange: (index: number, val: string) => void;
|
||||
onPaste: (e: React.ClipboardEvent<HTMLInputElement>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const OtpInputSlots: React.FC<OtpInputSlotsProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onPaste,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
const handleKeyDown = (
|
||||
index: number,
|
||||
e: React.KeyboardEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (e.key === 'Backspace' && value[index] === '') {
|
||||
if (index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = (
|
||||
index: number,
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const val = e.target.value.replace(/[^0-9]/g, '');
|
||||
const lastChar = val.slice(-1);
|
||||
|
||||
onChange(index, lastChar);
|
||||
|
||||
if (lastChar && index < 5) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="otp-input-row"
|
||||
style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5].map((index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(el) => {
|
||||
inputRefs.current[index] = el;
|
||||
}}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={1}
|
||||
value={value[index] || ''}
|
||||
onChange={(e) => handleInput(index, e)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
onPaste={onPaste}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '56px',
|
||||
textAlign: 'center',
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: value[index] ? '#6d28d9' : '#111827',
|
||||
backgroundColor: value[index] ? 'rgba(109,40,217,0.08)' : '#fafafa',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
outline: 'none',
|
||||
}}
|
||||
className="otp-slot"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OtpInputSlots;
|
||||
@@ -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,40 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Route, Redirect } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import SplashPage from '../pages/SplashPage';
|
||||
|
||||
interface Props {
|
||||
component: React.ComponentType<any>;
|
||||
path: string | string[];
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
const BYPASS_AUTH = false;
|
||||
|
||||
const ProtectedRouteLoader: React.FC = () => (
|
||||
<SplashPage title="Send support to loved ones from anywhere" />
|
||||
);
|
||||
|
||||
const ProtectedRoute: React.FC<Props> = ({ component: Component, ...rest }) => {
|
||||
const { user, profile, profileStatus } = useAuth();
|
||||
|
||||
return (
|
||||
<Route
|
||||
{...rest}
|
||||
render={(props) => {
|
||||
if (!user) {
|
||||
return <Redirect to="/auth" />;
|
||||
}
|
||||
if (profileStatus === 'loading') {
|
||||
return <ProtectedRouteLoader />;
|
||||
}
|
||||
if (profileStatus === 'missing') {
|
||||
return <Redirect to="/setup-profile" />;
|
||||
}
|
||||
return <Component {...props} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
@@ -1,94 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
import {
|
||||
cartOutline,
|
||||
medkitOutline,
|
||||
phonePortraitOutline,
|
||||
flashOutline,
|
||||
} from 'ionicons/icons';
|
||||
|
||||
interface QuickSupportActionCardProps {
|
||||
serviceType: 'grocery' | 'medication' | 'airtime' | 'electricity';
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const QuickSupportActionCard: React.FC<QuickSupportActionCardProps> = ({
|
||||
serviceType,
|
||||
label,
|
||||
onClick,
|
||||
}) => {
|
||||
const getIconAndColor = () => {
|
||||
switch (serviceType) {
|
||||
case 'grocery':
|
||||
return {
|
||||
icon: cartOutline,
|
||||
color: '#6d28d9',
|
||||
bg: 'rgba(109,40,217,0.1)',
|
||||
};
|
||||
case 'medication':
|
||||
return {
|
||||
icon: medkitOutline,
|
||||
color: '#ef4444',
|
||||
bg: 'rgba(239,68,68,0.1)',
|
||||
};
|
||||
case 'airtime':
|
||||
return {
|
||||
icon: phonePortraitOutline,
|
||||
color: '#3b82f6',
|
||||
bg: 'rgba(59,130,246,0.1)',
|
||||
};
|
||||
case 'electricity':
|
||||
return {
|
||||
icon: flashOutline,
|
||||
color: '#f59e0b',
|
||||
bg: 'rgba(245,158,11,0.1)',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: cartOutline,
|
||||
color: '#6d28d9',
|
||||
bg: 'rgba(109,40,217,0.1)',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const { icon, color, bg } = getIconAndColor();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '16px',
|
||||
border: 'none',
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: bg,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon icon={icon} style={{ fontSize: '20px', color }} />
|
||||
</div>
|
||||
<span style={{ fontSize: '14px', fontWeight: '700', color: '#111827' }}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickSupportActionCard;
|
||||
@@ -1,239 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
import {
|
||||
cartOutline,
|
||||
medkitOutline,
|
||||
flashOutline,
|
||||
timeOutline,
|
||||
checkmarkCircleOutline,
|
||||
ellipsisVertical,
|
||||
} from 'ionicons/icons';
|
||||
|
||||
export interface CareStatus {
|
||||
serviceType: 'grocery' | 'medication' | 'electricity' | 'airtime';
|
||||
label: string;
|
||||
statusText: string;
|
||||
isOk: boolean;
|
||||
}
|
||||
|
||||
export interface RecipientSummary {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
location: string;
|
||||
avatarUrl: string | null;
|
||||
statuses: CareStatus[];
|
||||
}
|
||||
|
||||
interface RecipientCareCardProps {
|
||||
recipient: RecipientSummary;
|
||||
onSendSupport: (id: string) => void;
|
||||
onMenuClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
const getIconForService = (type: string) => {
|
||||
switch (type) {
|
||||
case 'grocery':
|
||||
return cartOutline;
|
||||
case 'medication':
|
||||
return medkitOutline;
|
||||
case 'electricity':
|
||||
return flashOutline;
|
||||
default:
|
||||
return cartOutline;
|
||||
}
|
||||
};
|
||||
|
||||
const getColorForService = (type: string) => {
|
||||
switch (type) {
|
||||
case 'grocery':
|
||||
return '#6d28d9';
|
||||
case 'medication':
|
||||
return '#ef4444';
|
||||
case 'electricity':
|
||||
return '#f59e0b';
|
||||
default:
|
||||
return '#3b82f6';
|
||||
}
|
||||
};
|
||||
|
||||
const RecipientCareCard: React.FC<RecipientCareCardProps> = ({
|
||||
recipient,
|
||||
onSendSupport,
|
||||
onMenuClick,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '16px',
|
||||
width: '292px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '14px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '16px',
|
||||
backgroundColor: 'rgba(109,40,217,0.1)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{recipient.avatarUrl ? (
|
||||
<img
|
||||
src={recipient.avatarUrl}
|
||||
alt={recipient.firstName}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '700',
|
||||
color: '#6d28d9',
|
||||
}}
|
||||
>
|
||||
{recipient.firstName.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
}}
|
||||
>
|
||||
{recipient.firstName}
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#16a34a',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
margin: '2px 0 0',
|
||||
fontSize: '13px',
|
||||
fontWeight: '400',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
{recipient.location}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onMenuClick && onMenuClick(recipient.id)}
|
||||
style={{ background: 'transparent', border: 'none', padding: '4px' }}
|
||||
>
|
||||
<IonIcon
|
||||
icon={ellipsisVertical}
|
||||
style={{ fontSize: '20px', color: '#6b7280' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{recipient.statuses.map((status, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: '12px',
|
||||
padding: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<IonIcon
|
||||
icon={getIconForService(status.serviceType)}
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
color: getColorForService(status.serviceType),
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
}}
|
||||
>
|
||||
{status.label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
color: status.isOk ? '#16a34a' : '#f59e0b',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{status.statusText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<IonIcon
|
||||
icon={status.isOk ? checkmarkCircleOutline : timeOutline}
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
color: status.isOk ? '#16a34a' : '#f59e0b',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onSendSupport(recipient.id)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px',
|
||||
backgroundColor: '#6d28d9',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '700',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
marginTop: 'auto',
|
||||
}}
|
||||
>
|
||||
<IonIcon icon={cartOutline} style={{ fontSize: '18px' }} />
|
||||
Send Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipientCareCard;
|
||||
@@ -1,121 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
import { chevronForwardOutline } from 'ionicons/icons';
|
||||
|
||||
export interface RecipientListItem {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
relationship: string;
|
||||
location: string;
|
||||
lastActivityText?: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
interface RecipientListCardProps {
|
||||
recipient: RecipientListItem;
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
const RecipientListCard: React.FC<RecipientListCardProps> = ({
|
||||
recipient,
|
||||
onClick,
|
||||
}) => {
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
const shouldShowAvatar = Boolean(recipient.avatarUrl) && !imageFailed;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick(recipient.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '16px',
|
||||
margin: '0 20px 12px',
|
||||
gap: '14px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '52px',
|
||||
height: '52px',
|
||||
borderRadius: '16px',
|
||||
backgroundColor: 'rgba(109,40,217,0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{shouldShowAvatar ? (
|
||||
<img
|
||||
src={recipient.avatarUrl ?? ''}
|
||||
alt={recipient.firstName}
|
||||
onError={() => setImageFailed(true)}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{ fontSize: '20px', fontWeight: '700', color: '#6d28d9' }}
|
||||
>
|
||||
{recipient.firstName.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{recipient.firstName} {recipient.lastName}
|
||||
</h3>
|
||||
<span
|
||||
style={{ fontSize: '13px', fontWeight: '600', color: '#6d28d9' }}
|
||||
>
|
||||
{recipient.relationship}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
margin: '4px 0 0',
|
||||
fontSize: '13px',
|
||||
fontWeight: '400',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
{recipient.location}
|
||||
</div>
|
||||
{recipient.lastActivityText && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '2px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '400',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
Last activity: {recipient.lastActivityText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<IonIcon
|
||||
icon={chevronForwardOutline}
|
||||
style={{ fontSize: '18px', color: '#9ca3af', flexShrink: 0 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipientListCard;
|
||||
@@ -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;
|
||||
@@ -1,115 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonIcon, IonToggle } from '@ionic/react';
|
||||
import { chevronForwardOutline } from 'ionicons/icons';
|
||||
|
||||
interface SettingsRowProps {
|
||||
icon: string;
|
||||
iconColor?: string;
|
||||
iconBg?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClick?: () => void;
|
||||
type?: 'link' | 'toggle' | 'button';
|
||||
checked?: boolean;
|
||||
onToggle?: (checked: boolean) => void;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
const SettingsRow: React.FC<SettingsRowProps> = ({
|
||||
icon,
|
||||
iconColor = '#6d28d9',
|
||||
iconBg = 'rgba(109,40,217,0.1)',
|
||||
title,
|
||||
subtitle,
|
||||
onClick,
|
||||
type = 'link',
|
||||
checked = false,
|
||||
onToggle,
|
||||
destructive = false,
|
||||
}) => {
|
||||
const content = (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: destructive ? 'rgba(239,68,68,0.1)' : iconBg,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={icon}
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
color: destructive ? '#ef4444' : iconColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: destructive ? '#ef4444' : '#111827',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '400',
|
||||
color: '#6b7280',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{type === 'link' && (
|
||||
<IonIcon
|
||||
icon={chevronForwardOutline}
|
||||
style={{ fontSize: '18px', color: '#9ca3af' }}
|
||||
/>
|
||||
)}
|
||||
{type === 'toggle' && (
|
||||
<IonToggle
|
||||
checked={checked}
|
||||
onIonChange={(e) => onToggle && onToggle(e.detail.checked)}
|
||||
style={{ padding: 0 }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
gap: '14px',
|
||||
minHeight: '56px',
|
||||
cursor: onClick || type === 'toggle' ? 'pointer' : 'default',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
if (type === 'link' || type === 'button') {
|
||||
return (
|
||||
<button onClick={onClick} style={containerStyle}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div style={containerStyle}>{content}</div>;
|
||||
};
|
||||
|
||||
export default SettingsRow;
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SocialAuthButtonProps {
|
||||
provider: 'google' | 'apple';
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SocialAuthButton: React.FC<SocialAuthButtonProps> = ({
|
||||
provider,
|
||||
label,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="social-auth-button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="social-auth-button__icon" aria-hidden="true">
|
||||
{provider === 'google' && (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24">
|
||||
<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>
|
||||
)}
|
||||
{provider === 'apple' && (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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>
|
||||
<span className="social-auth-button__label">Continue with {label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default SocialAuthButton;
|
||||
@@ -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,313 +0,0 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import SplashPage from '../pages/SplashPage';
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
import { supabase } from '../supabase';
|
||||
import {
|
||||
buildCacheKey,
|
||||
readCache,
|
||||
writeCache,
|
||||
clearUserCache,
|
||||
} from '../utils/localCache';
|
||||
|
||||
export interface Profile {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
full_name: string;
|
||||
phone: string;
|
||||
country_of_residence: string;
|
||||
avatar_path: string | null;
|
||||
fcm_token: string | null;
|
||||
notification_push_enabled: boolean;
|
||||
notification_email_enabled: boolean;
|
||||
notification_sms_enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type ProfileStatus = 'idle' | 'loading' | 'loaded' | 'missing' | 'error';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
profile: Profile | null;
|
||||
profileStatus: ProfileStatus;
|
||||
refreshProfile: () => Promise<Profile | null>;
|
||||
setProfile: (profile: Profile | null) => void;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const BYPASS_AUTH = false;
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
profile: null,
|
||||
profileStatus: BYPASS_AUTH ? 'idle' : 'loading',
|
||||
refreshProfile: async () => null,
|
||||
setProfile: () => {},
|
||||
signOut: async () => {},
|
||||
});
|
||||
|
||||
const withTimeout = async <T,>(
|
||||
request: PromiseLike<T>,
|
||||
timeoutMs: number,
|
||||
label: string
|
||||
): Promise<T> => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`${label} timed out. Please check your connection and try again.`
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([Promise.resolve(request), timeout]);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [profile, setProfileState] = useState<Profile | null>(null);
|
||||
const [profileStatus, setProfileStatus] = useState<ProfileStatus>(
|
||||
BYPASS_AUTH ? 'idle' : 'loading'
|
||||
);
|
||||
const [initializing, setInitializing] = useState(!BYPASS_AUTH);
|
||||
const activeProfileFetchRef = useRef(0);
|
||||
const mountedRef = useRef(false);
|
||||
const currentUserIdRef = useRef<string | null>(null);
|
||||
|
||||
const applyProfile = useCallback((nextProfile: Profile | null) => {
|
||||
if (!mountedRef.current) return;
|
||||
setProfileState(nextProfile);
|
||||
setProfileStatus(nextProfile ? 'loaded' : 'missing');
|
||||
}, []);
|
||||
|
||||
const setProfile = useCallback(
|
||||
(nextProfile: Profile | null) => {
|
||||
activeProfileFetchRef.current += 1;
|
||||
applyProfile(nextProfile);
|
||||
},
|
||||
[applyProfile]
|
||||
);
|
||||
|
||||
const fetchProfile = useCallback(
|
||||
async (
|
||||
userId: string,
|
||||
options?: { showLoading?: boolean }
|
||||
): Promise<Profile | null> => {
|
||||
const fetchId = activeProfileFetchRef.current + 1;
|
||||
activeProfileFetchRef.current = fetchId;
|
||||
|
||||
if ((options?.showLoading ?? true) && mountedRef.current) {
|
||||
setProfileStatus('loading');
|
||||
}
|
||||
|
||||
const cacheKey = buildCacheKey(userId, 'profile');
|
||||
try {
|
||||
const cachedProfile = await readCache<Profile>(cacheKey);
|
||||
if (
|
||||
cachedProfile &&
|
||||
mountedRef.current &&
|
||||
fetchId === activeProfileFetchRef.current &&
|
||||
currentUserIdRef.current === userId
|
||||
) {
|
||||
applyProfile(cachedProfile);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AuthProvider] Failed to read cached profile', err);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await withTimeout(
|
||||
supabase.from('profiles').select('*').eq('id', userId).maybeSingle(),
|
||||
8000,
|
||||
'Profile loading'
|
||||
);
|
||||
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
fetchId !== activeProfileFetchRef.current ||
|
||||
currentUserIdRef.current !== userId
|
||||
) {
|
||||
return data ? (data as Profile) : null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error('[AuthProvider] Failed to fetch profile', error);
|
||||
setProfileState(null);
|
||||
setProfileStatus('error');
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextProfile = data ? (data as Profile) : null;
|
||||
applyProfile(nextProfile);
|
||||
if (nextProfile) {
|
||||
void writeCache(cacheKey, nextProfile);
|
||||
}
|
||||
return nextProfile;
|
||||
} catch (error) {
|
||||
if (
|
||||
mountedRef.current &&
|
||||
fetchId === activeProfileFetchRef.current &&
|
||||
currentUserIdRef.current === userId
|
||||
) {
|
||||
console.error('[AuthProvider] Profile fetch crashed', error);
|
||||
setProfileState(null);
|
||||
setProfileStatus('error');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[applyProfile]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
||||
if (BYPASS_AUTH) {
|
||||
setInitializing(false);
|
||||
setProfileStatus('idle');
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
activeProfileFetchRef.current += 1;
|
||||
};
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
supabase.auth
|
||||
.getSession()
|
||||
.then(async ({ data }) => {
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
const currentUser = data.session?.user ?? null;
|
||||
|
||||
const keepSignedIn = localStorage.getItem('kumusha_keep_signed_in');
|
||||
if (currentUser && keepSignedIn === 'false') {
|
||||
currentUserIdRef.current = null;
|
||||
setUser(null);
|
||||
setProfileState(null);
|
||||
setProfileStatus('idle');
|
||||
await clearUserCache(currentUser.id);
|
||||
await supabase.auth.signOut();
|
||||
if (!cancelled && mountedRef.current) {
|
||||
setInitializing(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
currentUserIdRef.current = currentUser?.id ?? null;
|
||||
setUser(currentUser);
|
||||
if (currentUser) {
|
||||
await fetchProfile(currentUser.id, { showLoading: false });
|
||||
} else {
|
||||
setProfileState(null);
|
||||
setProfileStatus('idle');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[AuthProvider] Failed to initialise session', error);
|
||||
if (!cancelled && mountedRef.current) {
|
||||
currentUserIdRef.current = null;
|
||||
setUser(null);
|
||||
setProfileState(null);
|
||||
setProfileStatus('missing');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled && mountedRef.current) {
|
||||
setInitializing(false);
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange(async (_event, session) => {
|
||||
const nextUser = session?.user ?? null;
|
||||
if (!mountedRef.current) return;
|
||||
const previousUserId = currentUserIdRef.current;
|
||||
currentUserIdRef.current = nextUser?.id ?? null;
|
||||
|
||||
if (nextUser) {
|
||||
if (nextUser.id !== previousUserId) {
|
||||
setProfileState(null);
|
||||
setProfileStatus('loading');
|
||||
}
|
||||
setUser(nextUser);
|
||||
await fetchProfile(nextUser.id, { showLoading: false });
|
||||
} else {
|
||||
activeProfileFetchRef.current += 1;
|
||||
setUser(null);
|
||||
setProfileState(null);
|
||||
setProfileStatus('idle');
|
||||
if (previousUserId) {
|
||||
await clearUserCache(previousUserId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current = false;
|
||||
activeProfileFetchRef.current += 1;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [fetchProfile]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
activeProfileFetchRef.current += 1;
|
||||
const currentUserId = currentUserIdRef.current;
|
||||
currentUserIdRef.current = null;
|
||||
setUser(null);
|
||||
setProfileState(null);
|
||||
setProfileStatus('idle');
|
||||
if (currentUserId) {
|
||||
await clearUserCache(currentUserId);
|
||||
}
|
||||
localStorage.removeItem('kumusha_keep_signed_in');
|
||||
await supabase.auth.signOut();
|
||||
}, []);
|
||||
|
||||
const refreshProfile = useCallback(() => {
|
||||
return user
|
||||
? fetchProfile(user.id, { showLoading: false })
|
||||
: Promise.resolve(null);
|
||||
}, [fetchProfile, user]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
profile,
|
||||
profileStatus,
|
||||
refreshProfile,
|
||||
setProfile,
|
||||
signOut,
|
||||
}),
|
||||
[profile, profileStatus, refreshProfile, setProfile, signOut, user]
|
||||
);
|
||||
|
||||
if (initializing) {
|
||||
return <SplashPage title="Send support to loved ones from anywhere" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -14,537 +14,122 @@ export type Database = {
|
||||
}
|
||||
public: {
|
||||
Tables: {
|
||||
activity_events: {
|
||||
habit_completions: {
|
||||
Row: {
|
||||
amount: number | null
|
||||
completed_on: string
|
||||
created_at: string
|
||||
event_at: string
|
||||
event_type: string
|
||||
habit_id: string
|
||||
id: string
|
||||
metadata: Json
|
||||
order_id: string | null
|
||||
recipient_id: string | null
|
||||
subtitle: string
|
||||
title: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
amount?: number | null
|
||||
completed_on: string
|
||||
created_at?: string
|
||||
event_at?: string
|
||||
event_type: string
|
||||
habit_id: string
|
||||
id?: string
|
||||
metadata?: Json
|
||||
order_id?: string | null
|
||||
recipient_id?: string | null
|
||||
subtitle: string
|
||||
title: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
amount?: number | null
|
||||
completed_on?: string
|
||||
created_at?: string
|
||||
event_at?: string
|
||||
event_type?: string
|
||||
habit_id?: string
|
||||
id?: string
|
||||
metadata?: Json
|
||||
order_id?: string | null
|
||||
recipient_id?: string | null
|
||||
subtitle?: string
|
||||
title?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
care_alerts: {
|
||||
Row: {
|
||||
alert_type: string
|
||||
body: string
|
||||
created_at: string
|
||||
dismissed_at: string | null
|
||||
due_at: string | null
|
||||
id: string
|
||||
recipient_id: string
|
||||
schedule_id: string | null
|
||||
service_type: string | null
|
||||
severity: string
|
||||
title: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
alert_type: string
|
||||
body: string
|
||||
created_at?: string
|
||||
dismissed_at?: string | null
|
||||
due_at?: string | null
|
||||
id?: string
|
||||
recipient_id: string
|
||||
schedule_id?: string | null
|
||||
service_type?: string | null
|
||||
severity: string
|
||||
title: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
alert_type?: string
|
||||
body?: string
|
||||
created_at?: string
|
||||
dismissed_at?: string | null
|
||||
due_at?: string | null
|
||||
id?: string
|
||||
recipient_id?: string
|
||||
schedule_id?: string | null
|
||||
service_type?: string | null
|
||||
severity?: string
|
||||
title?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "care_alerts_schedule_id_fkey"
|
||||
columns: ["schedule_id"]
|
||||
foreignKeyName: "habit_completions_habit_id_fkey"
|
||||
columns: ["habit_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "support_schedules"
|
||||
referencedRelation: "habits"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
merchants: {
|
||||
habits: {
|
||||
Row: {
|
||||
branch_name: string | null
|
||||
city: string
|
||||
country: string
|
||||
created_at: string
|
||||
id: string
|
||||
is_active: boolean
|
||||
merchant_type: string
|
||||
name: string
|
||||
}
|
||||
Insert: {
|
||||
branch_name?: string | null
|
||||
city: string
|
||||
country: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
is_active?: boolean
|
||||
merchant_type: string
|
||||
name: string
|
||||
}
|
||||
Update: {
|
||||
branch_name?: string | null
|
||||
city?: string
|
||||
country?: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
is_active?: boolean
|
||||
merchant_type?: string
|
||||
name?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
notifications: {
|
||||
Row: {
|
||||
body: string
|
||||
created_at: string
|
||||
id: string
|
||||
order_id: string | null
|
||||
priority: string
|
||||
read_at: string | null
|
||||
recipient_id: string | null
|
||||
title: string
|
||||
type: string
|
||||
user_id: string
|
||||
voucher_id: string | null
|
||||
}
|
||||
Insert: {
|
||||
body: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
order_id?: string | null
|
||||
priority: string
|
||||
read_at?: string | null
|
||||
recipient_id?: string | null
|
||||
title: string
|
||||
type: string
|
||||
user_id: string
|
||||
voucher_id?: string | null
|
||||
}
|
||||
Update: {
|
||||
body?: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
order_id?: string | null
|
||||
priority?: string
|
||||
read_at?: string | null
|
||||
recipient_id?: string | null
|
||||
title?: string
|
||||
type?: string
|
||||
user_id?: string
|
||||
voucher_id?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "notifications_voucher_id_fkey"
|
||||
columns: ["voucher_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "vouchers"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
payment_methods: {
|
||||
Row: {
|
||||
brand: string | null
|
||||
created_at: string
|
||||
expiry_month: number | null
|
||||
expiry_year: number | null
|
||||
id: string
|
||||
is_default: boolean
|
||||
last4: string | null
|
||||
provider: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
brand?: string | null
|
||||
created_at?: string
|
||||
expiry_month?: number | null
|
||||
expiry_year?: number | null
|
||||
id?: string
|
||||
is_default?: boolean
|
||||
last4?: string | null
|
||||
provider: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
brand?: string | null
|
||||
created_at?: string
|
||||
expiry_month?: number | null
|
||||
expiry_year?: number | null
|
||||
id?: string
|
||||
is_default?: boolean
|
||||
last4?: string | null
|
||||
provider?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
profiles: {
|
||||
Row: {
|
||||
avatar_path: string | null
|
||||
country_of_residence: string
|
||||
color: string
|
||||
created_at: string
|
||||
fcm_token: string | null
|
||||
first_name: string
|
||||
full_name: string
|
||||
id: string
|
||||
last_name: string
|
||||
notification_email_enabled: boolean
|
||||
notification_push_enabled: boolean
|
||||
notification_sms_enabled: boolean
|
||||
phone: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
avatar_path?: string | null
|
||||
country_of_residence: string
|
||||
created_at?: string
|
||||
fcm_token?: string | null
|
||||
first_name: string
|
||||
full_name: string
|
||||
id: string
|
||||
last_name: string
|
||||
notification_email_enabled?: boolean
|
||||
notification_push_enabled?: boolean
|
||||
notification_sms_enabled?: boolean
|
||||
phone: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
avatar_path?: string | null
|
||||
country_of_residence?: string
|
||||
created_at?: string
|
||||
fcm_token?: string | null
|
||||
first_name?: string
|
||||
full_name?: string
|
||||
id?: string
|
||||
last_name?: string
|
||||
notification_email_enabled?: boolean
|
||||
notification_push_enabled?: boolean
|
||||
notification_sms_enabled?: boolean
|
||||
phone?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
recipients: {
|
||||
Row: {
|
||||
archived_at: string | null
|
||||
city: string
|
||||
country: string
|
||||
created_at: string
|
||||
first_name: string
|
||||
id: string
|
||||
is_active: boolean
|
||||
is_archived: boolean
|
||||
last_name: string
|
||||
mobile_number: string
|
||||
photo_path: string | null
|
||||
pinned_at: string | null
|
||||
relationship: string
|
||||
sort_order: number
|
||||
updated_at: string
|
||||
name: string
|
||||
target_description: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
archived_at?: string | null
|
||||
city: string
|
||||
country: string
|
||||
color?: string
|
||||
created_at?: string
|
||||
first_name: string
|
||||
fcm_token?: string | null
|
||||
id?: string
|
||||
is_active?: boolean
|
||||
is_archived?: boolean
|
||||
last_name: string
|
||||
mobile_number: string
|
||||
photo_path?: string | null
|
||||
pinned_at?: string | null
|
||||
relationship: string
|
||||
sort_order?: number
|
||||
updated_at?: string
|
||||
name: string
|
||||
target_description?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
archived_at?: string | null
|
||||
city?: string
|
||||
country?: string
|
||||
color?: string
|
||||
created_at?: string
|
||||
first_name?: string
|
||||
fcm_token?: string | null
|
||||
id?: string
|
||||
is_active?: boolean
|
||||
is_archived?: boolean
|
||||
last_name?: string
|
||||
mobile_number?: string
|
||||
photo_path?: string | null
|
||||
pinned_at?: string | null
|
||||
relationship?: string
|
||||
sort_order?: number
|
||||
updated_at?: string
|
||||
name?: string
|
||||
target_description?: string | null
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
support_orders: {
|
||||
notes: {
|
||||
Row: {
|
||||
amount: number
|
||||
content: string
|
||||
created_at: string
|
||||
currency: string
|
||||
delivery_channel: string
|
||||
id: string
|
||||
merchant_id: string | null
|
||||
meter_number: string | null
|
||||
network: string | null
|
||||
note: string | null
|
||||
payment_method: string | null
|
||||
platform_fee: number
|
||||
recipient_id: string
|
||||
recurring_schedule_id: string | null
|
||||
service_type: string
|
||||
status: string
|
||||
total_amount: number
|
||||
updated_at: string
|
||||
is_favorite: boolean
|
||||
title: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
amount: number
|
||||
content: string
|
||||
created_at?: string
|
||||
currency?: string
|
||||
delivery_channel?: string
|
||||
id?: string
|
||||
merchant_id?: string | null
|
||||
meter_number?: string | null
|
||||
network?: string | null
|
||||
note?: string | null
|
||||
payment_method?: string | null
|
||||
platform_fee: number
|
||||
recipient_id: string
|
||||
recurring_schedule_id?: string | null
|
||||
service_type: string
|
||||
status: string
|
||||
total_amount: number
|
||||
updated_at?: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
amount?: number
|
||||
created_at?: string
|
||||
currency?: string
|
||||
delivery_channel?: string
|
||||
id?: string
|
||||
merchant_id?: string | null
|
||||
meter_number?: string | null
|
||||
network?: string | null
|
||||
note?: string | null
|
||||
payment_method?: string | null
|
||||
platform_fee?: number
|
||||
recipient_id?: string
|
||||
recurring_schedule_id?: string | null
|
||||
service_type?: string
|
||||
status?: string
|
||||
total_amount?: number
|
||||
updated_at?: string
|
||||
is_favorite?: boolean
|
||||
title: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "support_orders_merchant_id_fkey"
|
||||
columns: ["merchant_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "merchants"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "support_orders_recipient_id_fkey"
|
||||
columns: ["recipient_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "recipients"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "support_orders_recurring_schedule_id_fkey"
|
||||
columns: ["recurring_schedule_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "support_schedules"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
support_schedules: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
frequency_interval: number
|
||||
frequency_unit: string
|
||||
id: string
|
||||
is_active: boolean
|
||||
last_supported_at: string | null
|
||||
next_due_at: string
|
||||
recipient_id: string
|
||||
service_type: string
|
||||
updated_at: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null
|
||||
frequency_interval: number
|
||||
frequency_unit: string
|
||||
id?: string
|
||||
is_active?: boolean
|
||||
last_supported_at?: string | null
|
||||
next_due_at: string
|
||||
recipient_id: string
|
||||
service_type: string
|
||||
updated_at?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null
|
||||
frequency_interval?: number
|
||||
frequency_unit?: string
|
||||
content?: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
is_active?: boolean
|
||||
last_supported_at?: string | null
|
||||
next_due_at?: string
|
||||
recipient_id?: string
|
||||
service_type?: string
|
||||
updated_at?: string | null
|
||||
is_favorite?: boolean
|
||||
title?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "support_schedules_recipient_id_fkey"
|
||||
columns: ["recipient_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "recipients"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
Relationships: []
|
||||
}
|
||||
voucher_redemptions: {
|
||||
todos: {
|
||||
Row: {
|
||||
created_at: string
|
||||
description: string | null
|
||||
due_date: string | null
|
||||
id: string
|
||||
merchant_id: string
|
||||
order_id: string
|
||||
receipt_reference: string | null
|
||||
redeemed_amount: number
|
||||
redeemed_at: string
|
||||
voucher_id: string
|
||||
is_completed: boolean
|
||||
title: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
description?: string | null
|
||||
due_date?: string | null
|
||||
id?: string
|
||||
merchant_id: string
|
||||
order_id: string
|
||||
receipt_reference?: string | null
|
||||
redeemed_amount: number
|
||||
redeemed_at?: string
|
||||
voucher_id: string
|
||||
is_completed?: boolean
|
||||
title: string
|
||||
user_id?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
description?: string | null
|
||||
due_date?: string | null
|
||||
id?: string
|
||||
merchant_id?: string
|
||||
order_id?: string
|
||||
receipt_reference?: string | null
|
||||
redeemed_amount?: number
|
||||
redeemed_at?: string
|
||||
voucher_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "voucher_redemptions_voucher_id_fkey"
|
||||
columns: ["voucher_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "vouchers"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
vouchers: {
|
||||
Row: {
|
||||
created_at: string
|
||||
expires_at: string
|
||||
id: string
|
||||
order_id: string
|
||||
qr_payload: string
|
||||
redeemed_at: string | null
|
||||
redeemed_merchant_id: string | null
|
||||
status: string
|
||||
voucher_code: string
|
||||
voucher_type: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
expires_at: string
|
||||
id?: string
|
||||
order_id: string
|
||||
qr_payload: string
|
||||
redeemed_at?: string | null
|
||||
redeemed_merchant_id?: string | null
|
||||
status: string
|
||||
voucher_code: string
|
||||
voucher_type: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
expires_at?: string
|
||||
id?: string
|
||||
order_id?: string
|
||||
qr_payload?: string
|
||||
redeemed_at?: string | null
|
||||
redeemed_merchant_id?: string | null
|
||||
status?: string
|
||||
voucher_code?: string
|
||||
voucher_type?: string
|
||||
is_completed?: boolean
|
||||
title?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Capacitor } from '@capacitor/core';
|
||||
import App from './App';
|
||||
|
||||
// ── Ionic initialisation ──────────────────────────────────────────────────────
|
||||
// ?mode=md|ios overrides (used by Appcakes preview); otherwise follow the platform.
|
||||
// ?mode=md|ios overrides (used by AppSuite preview); otherwise follow the platform.
|
||||
const _urlMode = new URLSearchParams(window.location.search).get('mode');
|
||||
const _platform = Capacitor.getPlatform(); // 'ios' | 'android' | 'web'
|
||||
setupIonicReact({
|
||||
@@ -13,7 +13,7 @@ setupIonicReact({
|
||||
});
|
||||
|
||||
// Derive the studio parent origin dynamically so this works in both dev
|
||||
// (parent at localhost:3000) and production (parent at studio.appcakes.dev).
|
||||
// (parent at localhost:3000) and production (parent at appcakes.qqura.com).
|
||||
const studioOrigin = (() => {
|
||||
try {
|
||||
if (document.referrer) return new URL(document.referrer).origin;
|
||||
@@ -49,25 +49,23 @@ if (import.meta.hot) {
|
||||
}
|
||||
|
||||
// ── Session bridge ────────────────────────────────────────────────────────────
|
||||
// Post auth session to the Appcakes parent frame so the AI can test auth-protected
|
||||
// Post auth session to the AppSuite parent frame so the AI can test auth-protected
|
||||
// Edge Functions. Uses import.meta.glob so Vite never errors if supabase.ts is absent.
|
||||
(async () => {
|
||||
const mods = import.meta.glob('./supabase.ts', { eager: false });
|
||||
if ('./supabase.ts' in mods) {
|
||||
try {
|
||||
const { supabase } = await mods['./supabase.ts']() as { supabase: any };
|
||||
// supabase.auth.onAuthStateChange((_event: unknown, session: any) => {
|
||||
// setTimeout(() => {
|
||||
// window.parent.postMessage(
|
||||
// {
|
||||
// type: '__apsuite_session',
|
||||
// access_token: session?.access_token ?? null,
|
||||
// email: session?.user?.email ?? null,
|
||||
// },
|
||||
// studioOrigin,
|
||||
// );
|
||||
// }, 0);
|
||||
// });
|
||||
supabase.auth.onAuthStateChange((_event: unknown, session: any) => {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: '__apsuite_session',
|
||||
access_token: session?.access_token ?? null,
|
||||
email: session?.user?.email ?? null,
|
||||
},
|
||||
studioOrigin,
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
// supabase client failed to initialise — session bridge unavailable
|
||||
}
|
||||
@@ -75,17 +73,12 @@ if (import.meta.hot) {
|
||||
})();
|
||||
|
||||
// ── Runtime error reporting ───────────────────────────────────────────────────
|
||||
// Forward uncaught errors to the Appcakes dev server so the AI can read them.
|
||||
// Extract projectId from the preview URL (/preview/{projectId}/) so errors are
|
||||
// scoped per-project on the server rather than mixed into a global queue.
|
||||
const _previewMatch = window.location.pathname.match(/\/preview\/([^/]+)/);
|
||||
const _projectId = _previewMatch?.[1] ?? null;
|
||||
|
||||
// Forward uncaught errors to the AppSuite dev server so the AI can read them.
|
||||
function reportError(message: string, stack?: string) {
|
||||
fetch(`${studioOrigin}/api/agent/runtime-error`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, stack, projectId: _projectId }),
|
||||
body: JSON.stringify({ message, stack }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -1,722 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
IonContent,
|
||||
IonIcon,
|
||||
IonPage,
|
||||
IonRefresher,
|
||||
IonRefresherContent,
|
||||
IonSegment,
|
||||
IonSegmentButton,
|
||||
IonSkeletonText,
|
||||
IonLabel,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { fileTrayOutline } from 'ionicons/icons';
|
||||
import momImage from '../assets/mom.jpg';
|
||||
import dadImage from '../assets/dad.jpg';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ActivityListItem from '../components/ActivityListItem';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
import { buildCacheKey, readCache, writeCache } from '../utils/localCache';
|
||||
import '../styles/activity.css';
|
||||
import '../styles/recipients.css';
|
||||
|
||||
type RecipientRow = {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
photo_path: string | null;
|
||||
};
|
||||
|
||||
type MerchantRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
branch_name: string | null;
|
||||
};
|
||||
|
||||
type OrderRow = {
|
||||
id: string;
|
||||
service_type: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
recipient_id: string;
|
||||
merchant_id: string | null;
|
||||
recipients: RecipientRow | null;
|
||||
merchants: MerchantRow | null;
|
||||
};
|
||||
|
||||
type VoucherRow = {
|
||||
id: string;
|
||||
order_id: string;
|
||||
status: string;
|
||||
redeemed_at: string | null;
|
||||
redeemed_merchant_id: string | null;
|
||||
};
|
||||
|
||||
type RedemptionRow = {
|
||||
id: string;
|
||||
voucher_id: string;
|
||||
order_id: string;
|
||||
merchant_id: string;
|
||||
redeemed_at: string;
|
||||
};
|
||||
|
||||
type ActivityMetadata = {
|
||||
statusText?: string;
|
||||
avatarLabel?: string;
|
||||
avatarTone?: string;
|
||||
avatarImage?: string;
|
||||
voucherId?: string;
|
||||
};
|
||||
|
||||
type UnifiedActivityItem = {
|
||||
kind: 'voucher' | 'order';
|
||||
id: string;
|
||||
event_type: string;
|
||||
service_type: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
amount: number | null;
|
||||
event_at: string;
|
||||
order_id: string;
|
||||
metadata: ActivityMetadata;
|
||||
};
|
||||
|
||||
type ActivityTypeFilter =
|
||||
| 'all'
|
||||
| 'orders'
|
||||
| 'vouchers'
|
||||
| 'completed'
|
||||
| 'alerts';
|
||||
|
||||
type ActivityFilterRange = '7' | '30' | '90' | 'all';
|
||||
|
||||
type VoucherActivityStatus = 'created' | 'redeemed' | 'partially';
|
||||
|
||||
const previewUserId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
const dayLabel = (dateText: string) => {
|
||||
const date = new Date(dateText);
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
if (date.toDateString() === today.toDateString()) return 'Today';
|
||||
if (date.toDateString() === yesterday.toDateString()) return 'Yesterday';
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getMerchantDisplayName = (merchant?: MerchantRow | null) => {
|
||||
if (!merchant) return null;
|
||||
return `${merchant.name}${merchant.branch_name ? ` ${merchant.branch_name}` : ''}`;
|
||||
};
|
||||
|
||||
const getActivityContextLabel = (
|
||||
serviceType: string,
|
||||
statusText: string,
|
||||
merchantName?: string | null
|
||||
) => {
|
||||
const normalizedService = getNormalizedServiceType(serviceType);
|
||||
const normalizedStatus = statusText.toLowerCase();
|
||||
|
||||
if (normalizedService === 'grocery' || normalizedService === 'medication') {
|
||||
if (normalizedStatus.includes('redeemed')) {
|
||||
return merchantName ? `Redeemed at ${merchantName}` : 'Voucher redeemed';
|
||||
}
|
||||
if (normalizedStatus.includes('partial')) {
|
||||
return merchantName
|
||||
? `Partially redeemed at ${merchantName}`
|
||||
: 'Partially redeemed';
|
||||
}
|
||||
return merchantName
|
||||
? `Ready at ${merchantName}`
|
||||
: 'Voucher ready for collection';
|
||||
}
|
||||
|
||||
if (normalizedService === 'electricity') {
|
||||
return normalizedStatus.includes('completed')
|
||||
? 'Meter support delivered'
|
||||
: 'Meter support sent';
|
||||
}
|
||||
|
||||
if (normalizedService === 'airtime') {
|
||||
return normalizedStatus.includes('completed')
|
||||
? 'Top-up delivered'
|
||||
: 'Top-up sent';
|
||||
}
|
||||
|
||||
return 'Support sent';
|
||||
};
|
||||
|
||||
const getRecipientDisplayName = (recipient?: RecipientRow | null) => {
|
||||
if (!recipient) return 'Loved one';
|
||||
return `${recipient.first_name} ${recipient.last_name}`.trim();
|
||||
};
|
||||
|
||||
const getInitials = (name: string) =>
|
||||
name
|
||||
.split(' ')
|
||||
.map((part) => part.charAt(0))
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
const getNormalizedServiceType = (value: string) => {
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized.includes('grocery')) return 'grocery';
|
||||
if (normalized.includes('medication') || normalized.includes('pharmacy')) {
|
||||
return 'medication';
|
||||
}
|
||||
if (normalized.includes('airtime') || normalized.includes('data')) {
|
||||
return 'airtime';
|
||||
}
|
||||
if (normalized.includes('electricity') || normalized.includes('zesa')) {
|
||||
return 'electricity';
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const getNormalizedServiceLabel = (serviceType: string) => {
|
||||
const normalizedService = getNormalizedServiceType(serviceType);
|
||||
if (normalizedService === 'grocery') return 'Grocery voucher';
|
||||
if (normalizedService === 'medication') return 'Medication voucher';
|
||||
if (normalizedService === 'airtime') return 'Airtime & Data';
|
||||
if (normalizedService === 'electricity') return 'Electricity';
|
||||
return serviceType.charAt(0).toUpperCase() + serviceType.slice(1);
|
||||
};
|
||||
|
||||
const isVoucherService = (serviceType: string) => {
|
||||
const normalizedService = getNormalizedServiceType(serviceType);
|
||||
return normalizedService === 'grocery' || normalizedService === 'medication';
|
||||
};
|
||||
|
||||
const getVoucherStatus = (
|
||||
orderStatus: string,
|
||||
voucher?: VoucherRow,
|
||||
redemptions: RedemptionRow[] = []
|
||||
): VoucherActivityStatus => {
|
||||
const combinedStatus = `${orderStatus} ${voucher?.status ?? ''}`
|
||||
.toLowerCase()
|
||||
.replace(/_/g, ' ');
|
||||
|
||||
if (combinedStatus.includes('partial')) return 'partially';
|
||||
if (
|
||||
combinedStatus.includes('redeemed') ||
|
||||
Boolean(voucher?.redeemed_at) ||
|
||||
Boolean(voucher?.redeemed_merchant_id) ||
|
||||
redemptions.length > 0
|
||||
) {
|
||||
return 'redeemed';
|
||||
}
|
||||
|
||||
return 'created';
|
||||
};
|
||||
|
||||
const getOrderStatus = (status: string) => {
|
||||
const normalizedStatus = status.toLowerCase().replace(/_/g, ' ');
|
||||
if (
|
||||
normalizedStatus.includes('completed') ||
|
||||
normalizedStatus.includes('delivered') ||
|
||||
normalizedStatus.includes('active')
|
||||
) {
|
||||
return 'completed';
|
||||
}
|
||||
return 'created';
|
||||
};
|
||||
|
||||
const getActivityStatusTone = (_serviceType: string, statusText?: string) => {
|
||||
const normalizedStatus = statusText?.toLowerCase() ?? '';
|
||||
|
||||
if (normalizedStatus.includes('redeemed')) return 'success';
|
||||
if (normalizedStatus.includes('partial')) return 'partial';
|
||||
return 'warning';
|
||||
};
|
||||
|
||||
const getSeededRecipientImage = (
|
||||
firstName?: string | null,
|
||||
photoPath?: string | null
|
||||
) => {
|
||||
const normalizedPath = photoPath?.trim().toLowerCase();
|
||||
if (normalizedPath === 'mom.jpg' || normalizedPath === 'mum.jpg') {
|
||||
return momImage;
|
||||
}
|
||||
if (normalizedPath === 'dad.jpg' || normalizedPath === 'father.jpg') {
|
||||
return dadImage;
|
||||
}
|
||||
|
||||
const normalizedName = firstName?.trim().toLowerCase();
|
||||
if (normalizedName === 'mum' || normalizedName === 'mom') return momImage;
|
||||
if (normalizedName === 'dad' || normalizedName === 'father') return dadImage;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getRecipientAvatarUrl = async (recipient?: RecipientRow | null) => {
|
||||
if (!recipient) return undefined;
|
||||
|
||||
const seededImage = getSeededRecipientImage(
|
||||
recipient.first_name,
|
||||
recipient.photo_path
|
||||
);
|
||||
if (seededImage) return seededImage;
|
||||
|
||||
if (!recipient.photo_path) return undefined;
|
||||
|
||||
const { data } = await supabase.storage
|
||||
.from('recipient-photos')
|
||||
.createSignedUrl(recipient.photo_path, 3600);
|
||||
|
||||
return data?.signedUrl;
|
||||
};
|
||||
|
||||
const getAvatarTone = (serviceType: string) => {
|
||||
const normalizedService = getNormalizedServiceType(serviceType);
|
||||
if (normalizedService === 'medication') return 'mint';
|
||||
if (normalizedService === 'airtime') return 'sky';
|
||||
if (normalizedService === 'electricity') return 'gold';
|
||||
return 'lavender';
|
||||
};
|
||||
|
||||
const activityListMemory = new Map<string, UnifiedActivityItem[]>();
|
||||
|
||||
const ActivityPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user } = useAuth();
|
||||
const initialUserId = user?.id ?? previewUserId;
|
||||
const initialEvents = activityListMemory.get(initialUserId) ?? [];
|
||||
const [events, setEvents] = useState<UnifiedActivityItem[]>(initialEvents);
|
||||
const [loading, setLoading] = useState(initialEvents.length === 0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [range, setRange] = useState<ActivityFilterRange>('all');
|
||||
const [typeFilter, setTypeFilter] = useState<ActivityTypeFilter>('all');
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Light);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void loadActivity();
|
||||
}, [user?.id]);
|
||||
|
||||
const showError = (message: string) => {
|
||||
setError(message);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
const fetchOrdersForUser = async (userId: string) => {
|
||||
return supabase
|
||||
.from('support_orders')
|
||||
.select(
|
||||
'id,service_type,amount,status,created_at,recipient_id,merchant_id,recipients(id,first_name,last_name,photo_path),merchants(id,name,branch_name)'
|
||||
)
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false });
|
||||
};
|
||||
|
||||
const loadActivity = async (options?: { forceRefresh?: boolean }) => {
|
||||
const activeUserId = user?.id ?? previewUserId;
|
||||
const cacheKey = buildCacheKey(activeUserId, 'activityList');
|
||||
|
||||
let hasCache = false;
|
||||
if (!options?.forceRefresh) {
|
||||
const memoryCached = activityListMemory.get(activeUserId);
|
||||
if (memoryCached) {
|
||||
setEvents(memoryCached);
|
||||
setLoading(false);
|
||||
hasCache = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const cached = await readCache<UnifiedActivityItem[]>(cacheKey);
|
||||
if (cached && Array.isArray(cached)) {
|
||||
activityListMemory.set(activeUserId, cached);
|
||||
setEvents(cached);
|
||||
setLoading(false);
|
||||
hasCache = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[activity cache] error', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCache) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
let { data: ordersData, error: ordersError } =
|
||||
await fetchOrdersForUser(activeUserId);
|
||||
|
||||
if (!ordersError && user?.id && (ordersData ?? []).length === 0) {
|
||||
const previewResult = await fetchOrdersForUser(previewUserId);
|
||||
ordersData = previewResult.data;
|
||||
ordersError = previewResult.error;
|
||||
}
|
||||
|
||||
if (ordersError) {
|
||||
showError(ordersError.message || 'Failed to load activity');
|
||||
if (!hasCache) setEvents([]);
|
||||
if (!hasCache) setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const orders = (ordersData ?? []) as unknown as OrderRow[];
|
||||
const orderIds = orders.map((order) => order.id);
|
||||
const recipientIds = Array.from(
|
||||
new Set(orders.map((order) => order.recipient_id).filter(Boolean))
|
||||
);
|
||||
|
||||
const [recipientsRes, vouchersRes, redemptionsRes, merchantsRes] =
|
||||
await Promise.all([
|
||||
recipientIds.length > 0
|
||||
? supabase
|
||||
.from('recipients')
|
||||
.select('id,first_name,last_name,photo_path')
|
||||
.in('id', recipientIds)
|
||||
: Promise.resolve({ data: [], error: null }),
|
||||
orderIds.length > 0
|
||||
? supabase
|
||||
.from('vouchers')
|
||||
.select('id,order_id,status,redeemed_at,redeemed_merchant_id')
|
||||
.in('order_id', orderIds)
|
||||
: Promise.resolve({ data: [], error: null }),
|
||||
orderIds.length > 0
|
||||
? supabase
|
||||
.from('voucher_redemptions')
|
||||
.select('id,voucher_id,order_id,merchant_id,redeemed_at')
|
||||
.in('order_id', orderIds)
|
||||
: Promise.resolve({ data: [], error: null }),
|
||||
supabase.from('merchants').select('id,name,branch_name'),
|
||||
]);
|
||||
|
||||
if (
|
||||
recipientsRes.error ||
|
||||
vouchersRes.error ||
|
||||
redemptionsRes.error ||
|
||||
merchantsRes.error
|
||||
) {
|
||||
showError(
|
||||
recipientsRes.error?.message ||
|
||||
vouchersRes.error?.message ||
|
||||
redemptionsRes.error?.message ||
|
||||
merchantsRes.error?.message ||
|
||||
'Failed to load activity details'
|
||||
);
|
||||
if (!hasCache) setEvents([]);
|
||||
if (!hasCache) setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const recipientsById = new Map(
|
||||
((recipientsRes.data ?? []) as RecipientRow[]).map((recipient) => [
|
||||
recipient.id,
|
||||
recipient,
|
||||
])
|
||||
);
|
||||
const vouchersByOrderId = new Map(
|
||||
((vouchersRes.data ?? []) as VoucherRow[]).map((voucher) => [
|
||||
voucher.order_id,
|
||||
voucher,
|
||||
])
|
||||
);
|
||||
const redemptionsByOrderId = (
|
||||
(redemptionsRes.data ?? []) as RedemptionRow[]
|
||||
).reduce<Map<string, RedemptionRow[]>>((acc, redemption) => {
|
||||
const existing = acc.get(redemption.order_id) ?? [];
|
||||
existing.push(redemption);
|
||||
acc.set(redemption.order_id, existing);
|
||||
return acc;
|
||||
}, new Map());
|
||||
const merchantsById = new Map(
|
||||
((merchantsRes.data ?? []) as MerchantRow[]).map((merchant) => [
|
||||
merchant.id,
|
||||
merchant,
|
||||
])
|
||||
);
|
||||
|
||||
const mappedRows = await Promise.all(
|
||||
orders.map(async (order) => {
|
||||
const recipient =
|
||||
order.recipients ?? recipientsById.get(order.recipient_id) ?? null;
|
||||
const recipientName = getRecipientDisplayName(recipient);
|
||||
const serviceType = getNormalizedServiceType(order.service_type);
|
||||
const voucher = vouchersByOrderId.get(order.id);
|
||||
const redemptions = redemptionsByOrderId.get(order.id) ?? [];
|
||||
const latestRedemption = [...redemptions].sort(
|
||||
(a, b) =>
|
||||
new Date(b.redeemed_at).getTime() -
|
||||
new Date(a.redeemed_at).getTime()
|
||||
)[0];
|
||||
const statusText = isVoucherService(serviceType)
|
||||
? getVoucherStatus(order.status, voucher, redemptions)
|
||||
: getOrderStatus(order.status);
|
||||
const redemptionMerchant = latestRedemption?.merchant_id
|
||||
? merchantsById.get(latestRedemption.merchant_id)
|
||||
: null;
|
||||
const voucherMerchant = voucher?.redeemed_merchant_id
|
||||
? merchantsById.get(voucher.redeemed_merchant_id)
|
||||
: null;
|
||||
const orderMerchant =
|
||||
order.merchants ??
|
||||
(order.merchant_id ? merchantsById.get(order.merchant_id) : null);
|
||||
const merchantName =
|
||||
getMerchantDisplayName(redemptionMerchant) ??
|
||||
getMerchantDisplayName(voucherMerchant) ??
|
||||
getMerchantDisplayName(orderMerchant);
|
||||
const subtitle = getActivityContextLabel(
|
||||
serviceType,
|
||||
statusText,
|
||||
merchantName
|
||||
);
|
||||
const avatarImage = await getRecipientAvatarUrl(recipient);
|
||||
|
||||
return {
|
||||
kind: isVoucherService(serviceType) ? 'voucher' : 'order',
|
||||
id: order.id,
|
||||
order_id: order.id,
|
||||
event_type: serviceType,
|
||||
service_type: serviceType,
|
||||
title: getNormalizedServiceLabel(serviceType),
|
||||
subtitle,
|
||||
amount: Number(order.amount ?? 0),
|
||||
event_at: order.created_at,
|
||||
metadata: {
|
||||
statusText,
|
||||
avatarLabel: getInitials(recipientName),
|
||||
avatarTone: getAvatarTone(serviceType),
|
||||
avatarImage,
|
||||
voucherId: voucher?.id,
|
||||
},
|
||||
} satisfies UnifiedActivityItem;
|
||||
})
|
||||
);
|
||||
|
||||
const finalEvents = mappedRows.sort(
|
||||
(a, b) => new Date(b.event_at).getTime() - new Date(a.event_at).getTime()
|
||||
);
|
||||
|
||||
const nextHash = JSON.stringify(finalEvents);
|
||||
const currentHash = JSON.stringify(
|
||||
activityListMemory.get(activeUserId) ?? events
|
||||
);
|
||||
activityListMemory.set(activeUserId, finalEvents);
|
||||
if (nextHash !== currentHash) {
|
||||
setEvents(finalEvents);
|
||||
}
|
||||
setLoading(false);
|
||||
void writeCache(cacheKey, finalEvents);
|
||||
};
|
||||
|
||||
const filteredEvents = useMemo(() => {
|
||||
const threshold = new Date();
|
||||
if (range !== 'all') {
|
||||
const days = Number(range);
|
||||
threshold.setHours(0, 0, 0, 0);
|
||||
threshold.setDate(threshold.getDate() - (days - 1));
|
||||
}
|
||||
|
||||
return events.filter((item) => {
|
||||
if (
|
||||
range !== 'all' &&
|
||||
new Date(item.event_at).getTime() < threshold.getTime()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (typeFilter) {
|
||||
case 'all':
|
||||
return true;
|
||||
case 'orders':
|
||||
return item.kind === 'order';
|
||||
case 'vouchers':
|
||||
return item.kind === 'voucher';
|
||||
case 'completed': {
|
||||
const status = (item.metadata.statusText || '').toLowerCase();
|
||||
return (
|
||||
status.includes('completed') ||
|
||||
status.includes('redeemed') ||
|
||||
status.includes('partial')
|
||||
);
|
||||
}
|
||||
case 'alerts':
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}, [events, range, typeFilter]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
return filteredEvents.reduce<Record<string, UnifiedActivityItem[]>>(
|
||||
(acc, event) => {
|
||||
const label = dayLabel(event.event_at);
|
||||
acc[label] = acc[label] ?? [];
|
||||
acc[label].push(event);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
}, [filteredEvents]);
|
||||
|
||||
const handleRefresh = async (event: CustomEvent) => {
|
||||
await loadActivity({ forceRefresh: true });
|
||||
event.detail.complete();
|
||||
};
|
||||
|
||||
const handleOpenEvent = (event: UnifiedActivityItem) => {
|
||||
history.push(`/orders/${event.order_id}`, { parentRoot: '/activity' });
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage style={{ backgroundColor: '#fafafa' }}>
|
||||
<IonContent
|
||||
fullscreen
|
||||
className="activity-shell"
|
||||
style={
|
||||
{
|
||||
'--background': 'linear-gradient(180deg, #fafafa 0%, #f4f0ff 100%)',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': 'calc(var(--ion-safe-area-top, 0px) + 8px)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
|
||||
<IonRefresherContent />
|
||||
</IonRefresher>
|
||||
|
||||
<div className="activity-top-row" style={{ padding: '18px 0 0' }}>
|
||||
<div className="activity-title-block">
|
||||
<h1 className="activity-page-title">Activity</h1>
|
||||
<p className="activity-page-subtitle">Orders & care timeline</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="activity-type-filter-row">
|
||||
{(
|
||||
[
|
||||
'all',
|
||||
'orders',
|
||||
'vouchers',
|
||||
'completed',
|
||||
'alerts',
|
||||
] as ActivityTypeFilter[]
|
||||
).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
className={`activity-type-chip ${typeFilter === type ? 'active' : ''}`}
|
||||
onClick={() => setTypeFilter(type)}
|
||||
>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="activity-filter-row">
|
||||
<div className="activity-range-shell">
|
||||
<IonSegment
|
||||
value={range}
|
||||
className="activity-range-segment"
|
||||
onIonChange={(event) =>
|
||||
setRange((event.detail.value as ActivityFilterRange) ?? '30')
|
||||
}
|
||||
>
|
||||
<IonSegmentButton value="all">
|
||||
<IonLabel>All</IonLabel>
|
||||
</IonSegmentButton>
|
||||
<IonSegmentButton value="30">
|
||||
<IonLabel>30 days</IonLabel>
|
||||
</IonSegmentButton>
|
||||
<IonSegmentButton value="90">
|
||||
<IonLabel>90 days</IonLabel>
|
||||
</IonSegmentButton>
|
||||
</IonSegment>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ margin: '0 20px 12px', color: '#dc2626', fontSize: 13 }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="activity-grouped-list">
|
||||
{[1, 2, 3].map((item) => (
|
||||
<div key={item} className="activity-list-item">
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '70%', height: 15 }}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '45%', height: 12 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredEvents.length === 0 ? (
|
||||
<div className="empty-state-card">
|
||||
<IonIcon icon={fileTrayOutline} className="esc-icon" />
|
||||
<h2 className="esc-title">
|
||||
{typeFilter === 'all'
|
||||
? 'No activity in this range'
|
||||
: 'No matching activity'}
|
||||
</h2>
|
||||
<p className="esc-msg">
|
||||
Try a longer date range or switch the activity filter.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(groups).map(([label, items]) => (
|
||||
<div key={label}>
|
||||
<p className="activity-date-group-label">{label}</p>
|
||||
<div className="activity-grouped-list">
|
||||
{items.map((item) => (
|
||||
<ActivityListItem
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
eventType={item.event_type}
|
||||
title={item.title}
|
||||
subtitle={item.subtitle}
|
||||
amount={item.amount ?? undefined}
|
||||
statusText={item.metadata.statusText}
|
||||
statusTone={getActivityStatusTone(
|
||||
item.event_type,
|
||||
item.metadata.statusText
|
||||
)}
|
||||
avatarLabel={item.metadata.avatarLabel}
|
||||
avatarTone={item.metadata.avatarTone}
|
||||
avatarImage={item.metadata.avatarImage}
|
||||
onClick={() => handleOpenEvent(item)}
|
||||
onStatusClick={() => handleOpenEvent(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityPage;
|
||||
@@ -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,542 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import SplashPage from './SplashPage';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
useIonViewWillEnter,
|
||||
IonIcon,
|
||||
} from '@ionic/react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
|
||||
import AuthFormFields from '../components/AuthFormFields';
|
||||
import SocialAuthButton from '../components/SocialAuthButton';
|
||||
import momImage from '../assets/mom.jpg';
|
||||
import dadImage from '../assets/dad.jpg';
|
||||
import { checkmark, heart } from 'ionicons/icons';
|
||||
import '../styles/auth.css';
|
||||
import {
|
||||
fetchHomeSnapshot,
|
||||
preloadImageUrls,
|
||||
setWarmedHomeSnapshot,
|
||||
} from '../utils/homeSnapshot';
|
||||
import { hydrateHomeSnapshotImages } from '../utils/imageCache';
|
||||
import { buildCacheKey, writeCache } from '../utils/localCache';
|
||||
|
||||
const AuthPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const { user, profile, profileStatus } = useAuth();
|
||||
const signInTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const navigatedForUserRef = useRef<string | null>(null);
|
||||
const signInStartedAtRef = useRef<number | null>(null);
|
||||
const splashMinCompleteRef = useRef(false);
|
||||
const splashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const [tab, setTab] = useState<'login' | 'register'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [keepSignedIn, setKeepSignedIn] = useState(true);
|
||||
const [showPostLoginSplash, setShowPostLoginSplash] = useState(false);
|
||||
const [dashboardReady, setDashboardReady] = useState(false);
|
||||
const [splashMinReady, setSplashMinReady] = useState(false);
|
||||
const prefetchStartedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigatedForUserRef.current = null;
|
||||
signInStartedAtRef.current = null;
|
||||
splashMinCompleteRef.current = false;
|
||||
prefetchStartedRef.current = false;
|
||||
if (splashTimerRef.current) {
|
||||
clearTimeout(splashTimerRef.current);
|
||||
splashTimerRef.current = null;
|
||||
}
|
||||
setShowPostLoginSplash(false);
|
||||
setDashboardReady(false);
|
||||
setSplashMinReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signInTimeoutRef.current) {
|
||||
clearTimeout(signInTimeoutRef.current);
|
||||
signInTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (location.pathname !== '/auth' || profileStatus === 'loading') return;
|
||||
if (navigatedForUserRef.current === user.id) return;
|
||||
|
||||
if (profileStatus === 'missing') {
|
||||
if (showPostLoginSplash && !splashMinReady) return;
|
||||
navigatedForUserRef.current = user.id;
|
||||
setLoading(false);
|
||||
setShowPostLoginSplash(false);
|
||||
history.replace('/setup-profile');
|
||||
return;
|
||||
}
|
||||
|
||||
if (profileStatus === 'loaded' && profile) {
|
||||
if (!prefetchStartedRef.current) {
|
||||
prefetchStartedRef.current = true;
|
||||
prefetchHomeDashboard(user.id).then(() => {
|
||||
setDashboardReady(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (!dashboardReady || (showPostLoginSplash && !splashMinReady)) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigatedForUserRef.current = user.id;
|
||||
setLoading(false);
|
||||
setShowPostLoginSplash(false);
|
||||
history.replace('/home');
|
||||
}
|
||||
}, [
|
||||
user,
|
||||
profile,
|
||||
profileStatus,
|
||||
location.pathname,
|
||||
history,
|
||||
showPostLoginSplash,
|
||||
dashboardReady,
|
||||
splashMinReady,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (signInTimeoutRef.current) {
|
||||
clearTimeout(signInTimeoutRef.current);
|
||||
}
|
||||
if (splashTimerRef.current) {
|
||||
clearTimeout(splashTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setTab('login');
|
||||
setError(null);
|
||||
});
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setError(msg);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
const getRecipientAvatarUrl = async (
|
||||
photoPath?: string | null,
|
||||
firstName?: string | null
|
||||
) => {
|
||||
const normalizedPath = photoPath?.trim().toLowerCase();
|
||||
const normalizedName = firstName?.trim().toLowerCase();
|
||||
|
||||
if (normalizedPath === 'mom.jpg' || normalizedPath === 'mum.jpg') {
|
||||
return momImage;
|
||||
}
|
||||
if (normalizedPath === 'dad.jpg' || normalizedPath === 'father.jpg') {
|
||||
return dadImage;
|
||||
}
|
||||
if (normalizedName === 'mum' || normalizedName === 'mom') {
|
||||
return momImage;
|
||||
}
|
||||
if (normalizedName === 'dad' || normalizedName === 'father') {
|
||||
return dadImage;
|
||||
}
|
||||
|
||||
if (photoPath) {
|
||||
const { data } = supabase.storage
|
||||
.from('recipient-photos')
|
||||
.getPublicUrl(photoPath);
|
||||
if (data?.publicUrl) return data.publicUrl;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const prefetchHomeDashboard = async (userId: string) => {
|
||||
try {
|
||||
const snapshot = await fetchHomeSnapshot(userId, getRecipientAvatarUrl);
|
||||
const hydratedSnapshot = await hydrateHomeSnapshotImages(
|
||||
userId,
|
||||
snapshot
|
||||
);
|
||||
await preloadImageUrls([
|
||||
...hydratedSnapshot.imageUrls,
|
||||
...hydratedSnapshot.lovedOnes
|
||||
.map((person) => person.avatar)
|
||||
.filter(Boolean),
|
||||
...hydratedSnapshot.activities
|
||||
.map((activity) => activity.avatar)
|
||||
.filter(Boolean),
|
||||
]);
|
||||
const warmedSnapshot = setWarmedHomeSnapshot(userId, hydratedSnapshot);
|
||||
const cacheKey = buildCacheKey(userId, 'homeSnapshot');
|
||||
await writeCache(cacheKey, warmedSnapshot);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[prefetch] failed', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const startPostLoginSplash = () => {
|
||||
signInStartedAtRef.current = Date.now();
|
||||
splashMinCompleteRef.current = false;
|
||||
setShowPostLoginSplash(true);
|
||||
setSplashMinReady(false);
|
||||
|
||||
if (splashTimerRef.current) {
|
||||
clearTimeout(splashTimerRef.current);
|
||||
}
|
||||
|
||||
splashTimerRef.current = setTimeout(() => {
|
||||
splashMinCompleteRef.current = true;
|
||||
setSplashMinReady(true);
|
||||
}, 6000);
|
||||
};
|
||||
|
||||
const stopPostLoginSplash = () => {
|
||||
signInStartedAtRef.current = null;
|
||||
splashMinCompleteRef.current = false;
|
||||
prefetchStartedRef.current = false;
|
||||
if (splashTimerRef.current) {
|
||||
clearTimeout(splashTimerRef.current);
|
||||
splashTimerRef.current = null;
|
||||
}
|
||||
setShowPostLoginSplash(false);
|
||||
setDashboardReady(false);
|
||||
setSplashMinReady(false);
|
||||
};
|
||||
|
||||
const handleTabChange = (nextTab: 'login' | 'register') => {
|
||||
setTab(nextTab);
|
||||
setError(null);
|
||||
if (nextTab === 'login') {
|
||||
setConfirmPassword('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignUp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
|
||||
if (!normalizedEmail) {
|
||||
showError('Please enter your email address.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { error: signUpError } = await supabase.auth.signUp({
|
||||
email: normalizedEmail,
|
||||
password,
|
||||
});
|
||||
|
||||
if (signUpError) {
|
||||
showError(signUpError.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('kumusha_pending_verification_email', normalizedEmail);
|
||||
setLoading(false);
|
||||
history.replace('/verify-email', { state: { email: normalizedEmail } });
|
||||
};
|
||||
|
||||
const handleSignIn = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
|
||||
if (signInTimeoutRef.current) {
|
||||
clearTimeout(signInTimeoutRef.current);
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
signInTimeoutRef.current = setTimeout(() => {
|
||||
setLoading(false);
|
||||
stopPostLoginSplash();
|
||||
showError(
|
||||
'Sign in is taking too long. Please check your connection and try again.'
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
email: normalizedEmail,
|
||||
password,
|
||||
});
|
||||
|
||||
if (signInError) {
|
||||
if (signInTimeoutRef.current) {
|
||||
clearTimeout(signInTimeoutRef.current);
|
||||
signInTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
stopPostLoginSplash();
|
||||
|
||||
if (signInError.message.toLowerCase().includes('email not confirmed')) {
|
||||
await supabase.auth.resend({ type: 'signup', email: normalizedEmail });
|
||||
localStorage.setItem(
|
||||
'kumusha_pending_verification_email',
|
||||
normalizedEmail
|
||||
);
|
||||
history.push('/verify-email', {
|
||||
state: { email: normalizedEmail, resent: true },
|
||||
});
|
||||
} else {
|
||||
showError(signInError.message);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
'kumusha_keep_signed_in',
|
||||
keepSignedIn ? 'true' : 'false'
|
||||
);
|
||||
startPostLoginSplash();
|
||||
// Success: keep loading=true, wait for user/profile effect or timeout fallback
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = async () => {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
showError(
|
||||
'Google Sign-In is only available in the native app. Use email to sign in here.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await FirebaseAuthentication.signInWithGoogle();
|
||||
if (!result.credential?.idToken) throw new Error('Missing ID token');
|
||||
|
||||
const { error } = await supabase.auth.signInWithIdToken({
|
||||
provider: 'google',
|
||||
token: result.credential.idToken,
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
localStorage.setItem('kumusha_keep_signed_in', 'true');
|
||||
startPostLoginSplash();
|
||||
// keep loading true
|
||||
} catch (err: any) {
|
||||
stopPostLoginSplash();
|
||||
showError(err.message || 'Google sign in failed');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppleSignIn = async () => {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
showError(
|
||||
'Apple Sign-In is only available in the native app. Use email to sign in here.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await FirebaseAuthentication.signInWithApple({
|
||||
skipNativeAuth: true,
|
||||
});
|
||||
if (!result.credential?.idToken || !result.credential?.nonce)
|
||||
throw new Error('Missing token or nonce');
|
||||
|
||||
const displayName = result.user?.displayName ?? null;
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithIdToken({
|
||||
provider: 'apple',
|
||||
token: result.credential.idToken,
|
||||
nonce: result.credential.nonce,
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
localStorage.setItem('kumusha_keep_signed_in', 'true');
|
||||
startPostLoginSplash();
|
||||
|
||||
if (displayName && data.user) {
|
||||
const firstName = displayName.split(' ')[0] || 'Apple';
|
||||
const lastName = displayName.split(' ').slice(1).join(' ') || 'User';
|
||||
|
||||
await supabase.from('profiles').upsert(
|
||||
{
|
||||
id: data.user.id,
|
||||
full_name: displayName,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
phone: 'Pending',
|
||||
country_of_residence: 'Pending',
|
||||
},
|
||||
{ onConflict: 'id' }
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
stopPostLoginSplash();
|
||||
showError(err.message || 'Apple sign in failed');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent
|
||||
className="auth-content"
|
||||
fullscreen
|
||||
style={{
|
||||
'--background':
|
||||
'linear-gradient(180deg, #fafafa 0%, #f6f1ff 52%, #f4f0ff 100%)',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': '0px',
|
||||
'--padding-bottom': '0px',
|
||||
}}
|
||||
>
|
||||
<div className="auth-shell auth-shell--centered">
|
||||
<div className="auth-brand-block">
|
||||
<div className="auth-brand-header">
|
||||
<span className="auth-brand-name">Kumusha</span>
|
||||
</div>
|
||||
<h1 className="auth-heading">Care for home</h1>
|
||||
<p className="auth-subtitle">
|
||||
Trusted support for family back home.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="auth-card auth-card--elevated">
|
||||
<div
|
||||
className="auth-mode-toggle"
|
||||
role="tablist"
|
||||
aria-label="Authentication mode"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`auth-toggle-pill ${tab === 'login' ? 'active' : 'inactive'}`}
|
||||
onClick={() => handleTabChange('login')}
|
||||
disabled={loading}
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`auth-toggle-pill ${tab === 'register' ? 'active' : 'inactive'}`}
|
||||
onClick={() => handleTabChange('register')}
|
||||
disabled={loading}
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="auth-form-stack"
|
||||
onSubmit={tab === 'login' ? handleSignIn : handleSignUp}
|
||||
>
|
||||
<AuthFormFields
|
||||
mode={tab}
|
||||
email={email}
|
||||
password={password}
|
||||
confirmPassword={confirmPassword}
|
||||
onEmailChange={setEmail}
|
||||
onPasswordChange={setPassword}
|
||||
onConfirmPasswordChange={setConfirmPassword}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{tab === 'login' && (
|
||||
<button
|
||||
type="button"
|
||||
className="auth-keep-signed-in"
|
||||
onClick={() => !loading && setKeepSignedIn((prev) => !prev)}
|
||||
disabled={loading}
|
||||
>
|
||||
<div className="auth-keep-text">
|
||||
<span className="auth-keep-title">Keep me signed in</span>
|
||||
<span className="auth-keep-subtitle">
|
||||
Stay logged in on this device
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`auth-keep-check ${keepSignedIn ? 'active' : 'inactive'}`}
|
||||
>
|
||||
{keepSignedIn && <IonIcon icon={checkmark} />}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="auth-inline-message">{error}</div>
|
||||
) : null}
|
||||
|
||||
{tab === 'login' && (
|
||||
<div className="auth-meta-row">
|
||||
<span className="auth-meta-hint">Secure email sign in</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => history.push('/forgot-password')}
|
||||
className="auth-link-button"
|
||||
disabled={loading}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="auth-submit-btn"
|
||||
disabled={
|
||||
loading ||
|
||||
!email ||
|
||||
!password ||
|
||||
(tab === 'register' && !confirmPassword)
|
||||
}
|
||||
>
|
||||
{loading
|
||||
? 'Please wait...'
|
||||
: tab === 'login'
|
||||
? 'Sign In'
|
||||
: 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-divider">
|
||||
<span>Or continue with</span>
|
||||
</div>
|
||||
|
||||
<div className="auth-social-buttons">
|
||||
<SocialAuthButton
|
||||
provider="google"
|
||||
label="Google"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={loading}
|
||||
/>
|
||||
<SocialAuthButton
|
||||
provider="apple"
|
||||
label="Apple"
|
||||
onClick={handleAppleSignIn}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPostLoginSplash && (
|
||||
<div className="auth-splash-overlay">
|
||||
<SplashPage title="Send support to loved ones from anywhere" />
|
||||
</div>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthPage;
|
||||
@@ -1,344 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonInput,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from '@ionic/react';
|
||||
import { chevronBackOutline } from 'ionicons/icons';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import AvatarPicker from '../components/AvatarPicker';
|
||||
import '../styles/profile.css';
|
||||
|
||||
type Profile = {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone: string;
|
||||
country_of_residence: string;
|
||||
avatar_path: string | null;
|
||||
};
|
||||
|
||||
type PreviewProfilePayload = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone: string;
|
||||
country_of_residence: string;
|
||||
avatar_url: string | null;
|
||||
notification_push_enabled: boolean;
|
||||
notification_email_enabled: boolean;
|
||||
notification_sms_enabled: boolean;
|
||||
};
|
||||
|
||||
const PREVIEW_PROFILE_STORAGE_KEY = 'kumusha-preview-profile';
|
||||
|
||||
const getPreviewProfilePayload = (): PreviewProfilePayload => {
|
||||
const fallback: PreviewProfilePayload = {
|
||||
first_name: 'Sarah',
|
||||
last_name: 'Moyo',
|
||||
phone: '+44 7123 456789',
|
||||
country_of_residence: 'United Kingdom',
|
||||
avatar_url: null,
|
||||
notification_push_enabled: true,
|
||||
notification_email_enabled: true,
|
||||
notification_sms_enabled: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(PREVIEW_PROFILE_STORAGE_KEY);
|
||||
return saved ? { ...fallback, ...JSON.parse(saved) } : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const savePreviewProfilePayload = (payload: PreviewProfilePayload) => {
|
||||
localStorage.setItem(PREVIEW_PROFILE_STORAGE_KEY, JSON.stringify(payload));
|
||||
};
|
||||
|
||||
const EditProfilePage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user, refreshProfile } = useAuth();
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [country, setCountry] = useState('');
|
||||
const [avatarPath, setAvatarPath] = useState<string | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfile();
|
||||
}, [user?.id]);
|
||||
|
||||
const showError = (message: string) => {
|
||||
setError(message);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
const loadProfile = async () => {
|
||||
setLoading(true);
|
||||
|
||||
if (!user) {
|
||||
const preview = getPreviewProfilePayload();
|
||||
setFirstName(preview.first_name);
|
||||
setLastName(preview.last_name);
|
||||
setPhone(preview.phone);
|
||||
setCountry(preview.country_of_residence);
|
||||
setAvatarPath(null);
|
||||
setAvatarPreview(preview.avatar_url);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error: loadError } = await supabase
|
||||
.from('profiles')
|
||||
.select('id,first_name,last_name,phone,country_of_residence,avatar_path')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (loadError || !data) {
|
||||
showError(loadError?.message ?? 'Profile not found');
|
||||
const preview = getPreviewProfilePayload();
|
||||
setFirstName(preview.first_name);
|
||||
setLastName(preview.last_name);
|
||||
setPhone(preview.phone);
|
||||
setCountry(preview.country_of_residence);
|
||||
setAvatarPath(null);
|
||||
setAvatarPreview(preview.avatar_url);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = data as Profile;
|
||||
setFirstName(profile.first_name ?? '');
|
||||
setLastName(profile.last_name ?? '');
|
||||
setPhone(profile.phone ?? '');
|
||||
setCountry(profile.country_of_residence ?? '');
|
||||
setAvatarPath(profile.avatar_path);
|
||||
if (profile.avatar_path) {
|
||||
const { data: signed } = await supabase.storage
|
||||
.from('avatars')
|
||||
.createSignedUrl(profile.avatar_path, 3600);
|
||||
setAvatarPreview(signed?.signedUrl ?? null);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (history.length > 1) {
|
||||
history.goBack();
|
||||
} else {
|
||||
history.replace('/profile');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setAvatarFile(file);
|
||||
setAvatarPreview(
|
||||
typeof reader.result === 'string' ? reader.result : null
|
||||
);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
showError(
|
||||
'We could not preview that image. Please choose another photo.'
|
||||
);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const cleanFirstName = firstName.trim();
|
||||
const cleanLastName = lastName.trim();
|
||||
const cleanPhone = phone.trim();
|
||||
const cleanCountry = country.trim();
|
||||
|
||||
if (!cleanFirstName || !cleanLastName || !cleanPhone || !cleanCountry) {
|
||||
showError('Please complete all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
setSaving(true);
|
||||
savePreviewProfilePayload({
|
||||
...getPreviewProfilePayload(),
|
||||
first_name: cleanFirstName,
|
||||
last_name: cleanLastName,
|
||||
phone: cleanPhone,
|
||||
country_of_residence: cleanCountry,
|
||||
avatar_url: avatarPreview,
|
||||
});
|
||||
setSaving(false);
|
||||
history.replace('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
let nextAvatarPath = avatarPath;
|
||||
if (avatarFile) {
|
||||
const extension = avatarFile.name.split('.').pop() || 'jpg';
|
||||
nextAvatarPath = `${user.id}/${Date.now()}.${extension}`;
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('avatars')
|
||||
.upload(nextAvatarPath, avatarFile);
|
||||
if (uploadError) {
|
||||
showError(uploadError.message);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
first_name: cleanFirstName,
|
||||
last_name: cleanLastName,
|
||||
full_name: `${cleanFirstName} ${cleanLastName}`,
|
||||
phone: cleanPhone,
|
||||
country_of_residence: cleanCountry,
|
||||
avatar_path: nextAvatarPath,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', user.id);
|
||||
|
||||
if (updateError) {
|
||||
showError(updateError.message);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshProfile();
|
||||
setSaving(false);
|
||||
history.replace('/profile');
|
||||
};
|
||||
|
||||
const initials = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||
|
||||
return (
|
||||
<IonPage style={{ backgroundColor: '#fafafa' }}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar
|
||||
style={
|
||||
{
|
||||
'--background': '#fafafa',
|
||||
'--border-width': '0px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<IonButtons slot="start">
|
||||
<IonButton
|
||||
className="profile-back-button"
|
||||
fill="clear"
|
||||
onClick={handleGoBack}
|
||||
aria-label="Go back"
|
||||
style={
|
||||
{
|
||||
'--profile-action-accent': '#6d28d9',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle style={{ fontSize: 18, fontWeight: 700 }}>
|
||||
Edit profile
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent
|
||||
className="edit-profile-shell"
|
||||
style={
|
||||
{
|
||||
'--background': '#fafafa',
|
||||
'--padding-top': '8px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="edit-profile-card">
|
||||
<AvatarPicker
|
||||
previewUrl={avatarPreview}
|
||||
onFileChange={handleAvatarChange}
|
||||
initials={initials || undefined}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<IonInput
|
||||
className="epc-field"
|
||||
type="text"
|
||||
label="First name"
|
||||
labelPlacement="floating"
|
||||
placeholder="e.g. Tadiwa"
|
||||
value={firstName}
|
||||
disabled={loading || saving}
|
||||
onIonInput={(event) => setFirstName(event.detail.value ?? '')}
|
||||
/>
|
||||
<IonInput
|
||||
className="epc-field"
|
||||
type="text"
|
||||
label="Last name"
|
||||
labelPlacement="floating"
|
||||
placeholder="e.g. Moyo"
|
||||
value={lastName}
|
||||
disabled={loading || saving}
|
||||
onIonInput={(event) => setLastName(event.detail.value ?? '')}
|
||||
/>
|
||||
<IonInput
|
||||
className="epc-field"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
label="Mobile number"
|
||||
labelPlacement="floating"
|
||||
placeholder="e.g. +44 7123 456789"
|
||||
value={phone}
|
||||
disabled={loading || saving}
|
||||
onIonInput={(event) => setPhone(event.detail.value ?? '')}
|
||||
/>
|
||||
<IonInput
|
||||
className="epc-field"
|
||||
type="text"
|
||||
label="Country of residence"
|
||||
labelPlacement="floating"
|
||||
placeholder="e.g. United Kingdom"
|
||||
value={country}
|
||||
disabled={loading || saving}
|
||||
onIonInput={(event) => setCountry(event.detail.value ?? '')}
|
||||
/>
|
||||
{error && (
|
||||
<p style={{ margin: 0, color: '#dc2626', fontSize: 13 }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<IonButton
|
||||
type="submit"
|
||||
expand="block"
|
||||
className="epc-save-btn"
|
||||
disabled={loading || saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save profile'}
|
||||
</IonButton>
|
||||
</form>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditProfilePage;
|
||||
@@ -1,207 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonInput,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from '@ionic/react';
|
||||
import { chevronBackOutline, keyOutline } from 'ionicons/icons';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import '../styles/auth.css';
|
||||
|
||||
const ForgotPasswordPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
const showMessage = (message: string, kind: 'error' | 'status') => {
|
||||
if (kind === 'error') {
|
||||
setError(message);
|
||||
setStatus(null);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
} else {
|
||||
setStatus(message);
|
||||
setError(null);
|
||||
setTimeout(() => setStatus(null), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
if (!normalizedEmail) {
|
||||
showMessage('Enter your email address to continue', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
|
||||
const { error: resetError } =
|
||||
await supabase.auth.resetPasswordForEmail(normalizedEmail);
|
||||
|
||||
if (resetError) {
|
||||
showMessage(resetError.message, 'error');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage('Reset code sent', 'status');
|
||||
setLoading(false);
|
||||
history.push('/verify-reset', { state: { email: normalizedEmail } });
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage style={{ backgroundColor: '#fafafa' }}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar
|
||||
style={
|
||||
{
|
||||
'--background': 'transparent',
|
||||
'--border-width': '0px',
|
||||
'--color': '#111827',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<IonButtons slot="start">
|
||||
<IonButton
|
||||
fill="clear"
|
||||
onClick={() => history.goBack()}
|
||||
style={
|
||||
{
|
||||
'--color': '#111827',
|
||||
'--border-radius': '12px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
aria-label="Go back"
|
||||
>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle style={{ fontSize: '18px', fontWeight: 700 }}>
|
||||
Reset password
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent
|
||||
className="auth-content"
|
||||
style={
|
||||
{
|
||||
'--background': 'linear-gradient(180deg, #fafafa 0%, #f4f0ff 100%)',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': '0px',
|
||||
'--padding-bottom': '0px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="auth-shell">
|
||||
<div
|
||||
className="auth-intro-block"
|
||||
style={{ alignItems: 'flex-start', textAlign: 'left' }}
|
||||
>
|
||||
<div className="auth-icon-container">
|
||||
<IonIcon icon={keyOutline} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="auth-intro-heading">Forgot your password?</h1>
|
||||
<p className="auth-intro-body" style={{ marginTop: '8px' }}>
|
||||
Enter your account email and we'll send you a 6-digit reset
|
||||
code.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="auth-form-card"
|
||||
style={{ gap: '16px' }}
|
||||
>
|
||||
<IonInput
|
||||
type="email"
|
||||
label="Email address"
|
||||
labelPlacement="floating"
|
||||
value={email}
|
||||
onIonInput={(event) => setEmail(event.detail.value ?? '')}
|
||||
disabled={loading}
|
||||
placeholder="you@example.com"
|
||||
style={
|
||||
{
|
||||
'--background': '#fafafa',
|
||||
'--border-radius': '12px',
|
||||
'--padding-start': '14px',
|
||||
'--padding-end': '14px',
|
||||
'--highlight-color-focused': '#6d28d9',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="auth-status-text" style={{ color: '#dc2626' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{status && (
|
||||
<p className="auth-status-text" style={{ color: '#16a34a' }}>
|
||||
{status}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<IonButton
|
||||
type="submit"
|
||||
expand="block"
|
||||
disabled={loading || !email.trim()}
|
||||
style={
|
||||
{
|
||||
'--background': '#6d28d9',
|
||||
'--background-activated': '#5b21b6',
|
||||
'--border-radius': '999px',
|
||||
'--box-shadow': 'none',
|
||||
'--color': '#ffffff',
|
||||
height: '52px',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
marginTop: '4px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{loading ? 'Sending code...' : 'Send reset code'}
|
||||
</IonButton>
|
||||
</form>
|
||||
|
||||
<div className="auth-footer">
|
||||
<p className="auth-footer-text">
|
||||
Remembered it?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="auth-footer-link"
|
||||
onClick={() => history.replace('/auth')}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
</p>
|
||||
</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,30 +1,493 @@
|
||||
import React from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonRefresher,
|
||||
IonRefresherContent,
|
||||
IonSkeletonText,
|
||||
IonToolbar,
|
||||
} from "@ionic/react";
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
addOutline,
|
||||
analyticsOutline,
|
||||
checkmarkCircle,
|
||||
personCircleOutline,
|
||||
sparklesOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { PushNotifications } from '@capacitor/push-notifications';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Redirect, useHistory } from 'react-router-dom';
|
||||
import type { Tables } from '../database.types';
|
||||
import { supabase } from '../supabase';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import HabitCard from '../components/HabitCard';
|
||||
import HabitFormModal from '../components/HabitFormModal';
|
||||
|
||||
const Home: React.FC = () => (
|
||||
<IonPage>
|
||||
<IonHeader translucent className="home-header">
|
||||
<IonToolbar className="home-toolbar">
|
||||
<IonTitle>Home</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent fullscreen className="home-content">
|
||||
<div className="home-shell minimal-home-shell">
|
||||
<section className="minimal-welcome-card subtle-welcome-card">
|
||||
<h1>Welcome to your new app</h1>
|
||||
<p className="minimal-subtitle">
|
||||
Chat with the assistant to start adding features and pages
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
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 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 [error, setError] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [busyHabitId, setBusyHabitId] = useState<string | null>(null);
|
||||
const pushTokenRef = useRef<string | null>(null);
|
||||
const pushListenersReadyRef = useRef(false);
|
||||
|
||||
const showError = (message: string) => {
|
||||
setError(message);
|
||||
window.setTimeout(() => setError(''), 4000);
|
||||
};
|
||||
|
||||
const registerPushNotifications = useCallback(
|
||||
async (currentUserId: string) => {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pushListenersReadyRef.current) {
|
||||
pushListenersReadyRef.current = true;
|
||||
|
||||
await PushNotifications.removeAllListeners();
|
||||
|
||||
PushNotifications.addListener('registration', async (token) => {
|
||||
if (pushTokenRef.current === token.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pushTokenRef.current = token.value;
|
||||
const { error: updateError } = await supabase
|
||||
.from('habits')
|
||||
.update({ fcm_token: token.value })
|
||||
.eq('user_id', currentUserId);
|
||||
|
||||
if (updateError) {
|
||||
showError(updateError.message);
|
||||
}
|
||||
});
|
||||
|
||||
PushNotifications.addListener(
|
||||
'registrationError',
|
||||
(registrationError) => {
|
||||
showError(registrationError.error ?? 'Push registration failed.');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const permissionStatus = await PushNotifications.checkPermissions();
|
||||
if (permissionStatus.receive === 'prompt') {
|
||||
const requested = await PushNotifications.requestPermissions();
|
||||
if (requested.receive !== 'granted') {
|
||||
return;
|
||||
}
|
||||
} else if (permissionStatus.receive !== 'granted') {
|
||||
return;
|
||||
}
|
||||
|
||||
await PushNotifications.register();
|
||||
},
|
||||
[showError]
|
||||
);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
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),
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}, [habits, today]);
|
||||
|
||||
const totalCompletedToday = habits.filter(
|
||||
(habit) => habitStats[habit.id]?.doneToday
|
||||
).length;
|
||||
const longestStreak = habits.reduce(
|
||||
(max, habit) => Math.max(max, habitStats[habit.id]?.streak ?? 0),
|
||||
0
|
||||
);
|
||||
const completionRatio = habits.length
|
||||
? totalCompletedToday / habits.length
|
||||
: 0;
|
||||
const pendingCount = Math.max(habits.length - totalCompletedToday, 0);
|
||||
|
||||
const createHabit = async (values: {
|
||||
name: string;
|
||||
targetDescription: string;
|
||||
color: string;
|
||||
}) => {
|
||||
if (!userId) throw new Error('You need to be logged in.');
|
||||
|
||||
const { error: insertError } = await supabase.from('habits').insert({
|
||||
user_id: userId,
|
||||
name: values.name,
|
||||
target_description: values.targetDescription || null,
|
||||
color: values.color,
|
||||
});
|
||||
|
||||
if (insertError) throw insertError;
|
||||
await loadDashboard();
|
||||
};
|
||||
|
||||
const toggleHabit = async (habit: HabitWithMeta) => {
|
||||
if (!userId) return;
|
||||
setBusyHabitId(habit.id);
|
||||
|
||||
const existing = habit.completions.find(
|
||||
(entry) => entry.completed_on === today
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
const { error: deleteError } = await supabase
|
||||
.from('habit_completions')
|
||||
.delete()
|
||||
.eq('id', existing.id);
|
||||
if (deleteError) showError(deleteError.message);
|
||||
} else {
|
||||
const { error: insertError } = await supabase
|
||||
.from('habit_completions')
|
||||
.insert({
|
||||
habit_id: habit.id,
|
||||
user_id: userId,
|
||||
completed_on: today,
|
||||
});
|
||||
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 (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border home-header">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="end">
|
||||
<IonButton
|
||||
className="soft-icon-button"
|
||||
onClick={() => history.push('/stats')}
|
||||
>
|
||||
<IonIcon icon={analyticsOutline} />
|
||||
</IonButton>
|
||||
<IonButton
|
||||
className="soft-icon-button"
|
||||
onClick={() => history.push('/account')}
|
||||
>
|
||||
<IonIcon icon={personCircleOutline} />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent fullscreen className="home-content">
|
||||
<IonRefresher
|
||||
slot="fixed"
|
||||
onIonRefresh={async (e) => {
|
||||
await loadDashboard();
|
||||
e.detail.complete();
|
||||
}}
|
||||
>
|
||||
<IonRefresherContent />
|
||||
</IonRefresher>
|
||||
|
||||
<div className="dashboard-shell home-shell ion-padding">
|
||||
<section className="habits-hero">
|
||||
<div className="habits-hero-top">
|
||||
<div>
|
||||
<p className="hero-kicker">Habits ({habits.length})</p>
|
||||
<div className="hero-title-wrap">
|
||||
<h1>Daily</h1>
|
||||
<span>Weekly</span>
|
||||
</div>
|
||||
</div>
|
||||
<IonButton
|
||||
className="hero-add-button"
|
||||
fill="clear"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
<IonIcon icon={addOutline} />
|
||||
</IonButton>
|
||||
</div>
|
||||
|
||||
<div className="hero-progress-track" aria-hidden="true">
|
||||
<div
|
||||
className="hero-progress-fill"
|
||||
style={{ width: `${Math.max(completionRatio * 100, 6)}%` }}
|
||||
>
|
||||
<span className="hero-progress-dot" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero-progress-meta">
|
||||
<span>{totalCompletedToday} completed today</span>
|
||||
<span>{Math.round(completionRatio * 100)}% completed</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading ? (
|
||||
<div className="home-routine-panel">
|
||||
<div className="stack-list home-habit-stack">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
className="habit-card habit-card--editorial habit-card-skeleton"
|
||||
key={`habit-skeleton-${index}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-check"
|
||||
/>
|
||||
|
||||
<div className="habit-card-main">
|
||||
<div className="habit-copy">
|
||||
<div className="habit-copy-topline">
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-title"
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-color-pill"
|
||||
/>
|
||||
</div>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-description"
|
||||
/>
|
||||
<div className="habit-metrics-row">
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-metric"
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-metric short"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IonSkeletonText
|
||||
animated
|
||||
className="habit-skeleton-action"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="inline-message error-card home-inline-card">
|
||||
<h3>Couldn't load your habits</h3>
|
||||
<p>{error}</p>
|
||||
<IonButton onClick={loadDashboard}>Try Again</IonButton>
|
||||
</div>
|
||||
) : habits.length === 0 ? (
|
||||
<div className="home-routine-panel">
|
||||
<EmptyState
|
||||
title="Start your first routine"
|
||||
message="Create one small daily habit and begin building your streak story today."
|
||||
actionLabel="Create First Habit"
|
||||
onAction={() => setShowCreateModal(true)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<section className="home-routine-panel">
|
||||
<div className="stack-list home-habit-stack">
|
||||
{habits.map((habit) => (
|
||||
<HabitCard
|
||||
key={habit.id}
|
||||
habit={habit}
|
||||
completedToday={habitStats[habit.id]?.doneToday ?? false}
|
||||
currentStreak={habitStats[habit.id]?.streak ?? 0}
|
||||
completionRate={habitStats[habit.id]?.weekRate ?? 0}
|
||||
onToggle={() => toggleHabit(habit)}
|
||||
onDelete={() => deleteHabit(habit.id)}
|
||||
busy={busyHabitId === habit.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="home-complete-section">
|
||||
<p className="home-section-label">Momentum</p>
|
||||
<div className="home-summary-card">
|
||||
<div
|
||||
className="summary-ring"
|
||||
style={{
|
||||
background: `conic-gradient(#7c3aed 0deg, #a855f7 ${completionRatio * 360}deg, rgba(109, 40, 217, 0.08) ${completionRatio * 360}deg 360deg)`,
|
||||
}}
|
||||
>
|
||||
<div className="summary-ring-inner">
|
||||
{Math.round(completionRatio * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-copy">
|
||||
<h3>Keep your rhythm</h3>
|
||||
<p>
|
||||
{pendingCount === 0
|
||||
? 'Everything is complete today. Enjoy the streak.'
|
||||
: `${pendingCount} habit${pendingCount === 1 ? '' : 's'} left to protect today's routine.`}
|
||||
</p>
|
||||
<div className="summary-metrics">
|
||||
<span>
|
||||
<IonIcon icon={sparklesOutline} /> {totalCompletedToday}{' '}
|
||||
done
|
||||
</span>
|
||||
<span>
|
||||
<IonIcon icon={checkmarkCircle} /> {longestStreak} day
|
||||
best streak
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<HabitFormModal
|
||||
isOpen={showCreateModal}
|
||||
onDismiss={() => setShowCreateModal(false)}
|
||||
onSubmit={createHabit}
|
||||
/>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
||||
@@ -1,900 +0,0 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
IonContent,
|
||||
IonIcon,
|
||||
IonPage,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import {
|
||||
chevronForwardOutline,
|
||||
ellipsisVertical,
|
||||
flashOutline,
|
||||
heart,
|
||||
notificationsOutline,
|
||||
phonePortraitOutline,
|
||||
checkmarkCircleOutline,
|
||||
timeOutline,
|
||||
wifiOutline,
|
||||
medkitOutline,
|
||||
attach,
|
||||
attachOutline,
|
||||
trashOutline,
|
||||
pinOutline,
|
||||
} from 'ionicons/icons';
|
||||
import momImage from '../assets/mom.jpg';
|
||||
import dadImage from '../assets/dad.jpg';
|
||||
import basketIcon from '../assets/basket.png';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import DashboardSkeleton from '../components/DashboardSkeleton';
|
||||
import { formatMoney } from '../utils/formatMoney';
|
||||
import {
|
||||
buildCacheKey,
|
||||
readCache,
|
||||
removeCache,
|
||||
writeCache,
|
||||
} from '../utils/localCache';
|
||||
import {
|
||||
fetchHomeSnapshot,
|
||||
buildSnapshotHash,
|
||||
getWarmedHomeSnapshot,
|
||||
HomeAlert,
|
||||
SupportCategoryTone,
|
||||
} from '../utils/homeSnapshot';
|
||||
import { hydrateHomeSnapshotImages } from '../utils/imageCache';
|
||||
import '../styles/home.css';
|
||||
|
||||
type ActivityStatusTone = 'success' | 'warning' | 'info' | 'partial';
|
||||
|
||||
type HomeRenderableIcon = {
|
||||
iconType: 'ion' | 'image';
|
||||
icon?: any;
|
||||
iconSrc?: string;
|
||||
iconAlt?: string;
|
||||
};
|
||||
|
||||
const quickActions: Array<
|
||||
HomeRenderableIcon & {
|
||||
label: string;
|
||||
helper: string;
|
||||
tone: SupportCategoryTone;
|
||||
}
|
||||
> = [
|
||||
{
|
||||
label: 'Grocery',
|
||||
helper: 'Voucher for partner stores',
|
||||
iconType: 'image',
|
||||
iconSrc: basketIcon,
|
||||
iconAlt: 'Groceries',
|
||||
tone: 'grocery',
|
||||
},
|
||||
{
|
||||
label: 'Medication',
|
||||
helper: 'Voucher for trusted pharmacies',
|
||||
iconType: 'ion',
|
||||
icon: medkitOutline,
|
||||
tone: 'medication',
|
||||
},
|
||||
{
|
||||
label: 'Airtime & Data',
|
||||
helper: 'Top up mobile credit quickly',
|
||||
iconType: 'ion',
|
||||
icon: wifiOutline,
|
||||
tone: 'airtime',
|
||||
},
|
||||
{
|
||||
label: 'Electricity',
|
||||
helper: 'Send ZESA meter support',
|
||||
iconType: 'ion',
|
||||
icon: flashOutline,
|
||||
tone: 'electricity',
|
||||
},
|
||||
];
|
||||
|
||||
const renderHomeIcon = (item: HomeRenderableIcon) => {
|
||||
if (item.iconType === 'image' && item.iconSrc) {
|
||||
const isPill = item.iconSrc.includes('pill');
|
||||
const isBasket = item.iconSrc.includes('basket');
|
||||
return (
|
||||
<img
|
||||
src={item.iconSrc}
|
||||
alt={item.iconAlt ?? ''}
|
||||
className={`home-custom-icon ${isPill ? 'is-pill' : ''} ${isBasket ? 'is-basket' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return item.icon ? <IonIcon icon={item.icon} /> : null;
|
||||
};
|
||||
|
||||
const HomePage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user, profile, profileStatus } = useAuth();
|
||||
const [alerts, setAlerts] = useState<HomeAlert[]>([]);
|
||||
const [alertsLoading, setAlertsLoading] = useState(true);
|
||||
const [alertsError, setAlertsError] = useState<string | null>(null);
|
||||
const [lovedOnes, setLovedOnes] = useState<any[]>([]);
|
||||
const [lovedOnesLoading, setLovedOnesLoading] = useState(true);
|
||||
const [lovedOnesError, setLovedOnesError] = useState<string | null>(null);
|
||||
const [activities, setActivities] = useState<any[]>([]);
|
||||
const [activitiesLoading, setActivitiesLoading] = useState(true);
|
||||
const [activitiesError, setActivitiesError] = useState<string | null>(null);
|
||||
const [monthlySentTotal, setMonthlySentTotal] = useState(0);
|
||||
const [selectedLovedOne, setSelectedLovedOne] = useState<any | null>(null);
|
||||
const [lovedOneActionLoading, setLovedOneActionLoading] = useState(false);
|
||||
const [homeDataUserId, setHomeDataUserId] = useState<string | null>(null);
|
||||
const [profileAvatarUrl, setProfileAvatarUrl] = useState<string | null>(null);
|
||||
const lastSnapshotHashRef = useRef<string | null>(null);
|
||||
|
||||
const getProfileAvatarUrl = (avatarPath?: string | null) => {
|
||||
if (!avatarPath) return null;
|
||||
|
||||
const { data } = supabase.storage.from('avatars').getPublicUrl(avatarPath);
|
||||
return data.publicUrl || null;
|
||||
};
|
||||
|
||||
const getSeededRecipientImage = (
|
||||
firstName?: string | null,
|
||||
photoPath?: string | null
|
||||
) => {
|
||||
const normalizedPath = photoPath?.trim().toLowerCase();
|
||||
if (normalizedPath === 'mom.jpg' || normalizedPath === 'mum.jpg') {
|
||||
return momImage;
|
||||
}
|
||||
if (normalizedPath === 'dad.jpg' || normalizedPath === 'father.jpg') {
|
||||
return dadImage;
|
||||
}
|
||||
|
||||
const normalizedName = firstName?.trim().toLowerCase();
|
||||
if (normalizedName === 'mum' || normalizedName === 'mom') return momImage;
|
||||
if (normalizedName === 'dad' || normalizedName === 'father') {
|
||||
return dadImage;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getRecipientAvatarUrl = async (
|
||||
photoPath?: string | null,
|
||||
firstName?: string | null
|
||||
) => {
|
||||
const seededImage = getSeededRecipientImage(firstName, photoPath);
|
||||
if (seededImage) return seededImage;
|
||||
|
||||
if (photoPath) {
|
||||
const { data } = supabase.storage
|
||||
.from('recipient-photos')
|
||||
.getPublicUrl(photoPath);
|
||||
if (data?.publicUrl) return data.publicUrl;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Light);
|
||||
if (user?.id) {
|
||||
void loadHomePageData(user.id);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id && profileStatus === 'loaded' && homeDataUserId !== user.id) {
|
||||
void loadHomePageData(user.id);
|
||||
}
|
||||
}, [homeDataUserId, profileStatus, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) {
|
||||
setProfileAvatarUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const avatarCacheKey = buildCacheKey(user.id, 'profileAvatarUrl');
|
||||
let cancelled = false;
|
||||
|
||||
const loadAvatar = async () => {
|
||||
const cachedAvatarUrl = await readCache<string>(avatarCacheKey);
|
||||
if (!cancelled && cachedAvatarUrl) {
|
||||
setProfileAvatarUrl(cachedAvatarUrl);
|
||||
}
|
||||
|
||||
const nextAvatarUrl = getProfileAvatarUrl(profile?.avatar_path);
|
||||
if (!cancelled) {
|
||||
setProfileAvatarUrl(nextAvatarUrl);
|
||||
}
|
||||
|
||||
if (nextAvatarUrl) {
|
||||
void writeCache(avatarCacheKey, nextAvatarUrl);
|
||||
}
|
||||
};
|
||||
|
||||
void loadAvatar();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [profile?.avatar_path, user?.id]);
|
||||
|
||||
const loadHomePageData = async (activeUserId: string) => {
|
||||
setHomeDataUserId(activeUserId);
|
||||
|
||||
const cacheKey = buildCacheKey(activeUserId, 'homeSnapshot');
|
||||
let hasCache = false;
|
||||
try {
|
||||
const warmedSnapshot = getWarmedHomeSnapshot(activeUserId);
|
||||
const cached = warmedSnapshot ?? (await readCache<any>(cacheKey));
|
||||
const isCurrentUserCache = cached?.userId === activeUserId;
|
||||
if (
|
||||
cached &&
|
||||
isCurrentUserCache &&
|
||||
typeof cached === 'object' &&
|
||||
Array.isArray(cached.alerts) &&
|
||||
Array.isArray(cached.lovedOnes) &&
|
||||
Array.isArray(cached.activities) &&
|
||||
typeof cached.monthlySentTotal === 'number'
|
||||
) {
|
||||
setAlerts(cached.alerts);
|
||||
setLovedOnes(cached.lovedOnes);
|
||||
setActivities(cached.activities);
|
||||
setMonthlySentTotal(cached.monthlySentTotal);
|
||||
setAlertsLoading(false);
|
||||
setLovedOnesLoading(false);
|
||||
setActivitiesLoading(false);
|
||||
lastSnapshotHashRef.current = buildSnapshotHash({
|
||||
monthlySentTotal: cached.monthlySentTotal,
|
||||
alerts: cached.alerts,
|
||||
lovedOnes: cached.lovedOnes,
|
||||
activities: cached.activities,
|
||||
imageUrls: cached.imageUrls ?? [],
|
||||
});
|
||||
hasCache = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[home cache] error reading', err);
|
||||
}
|
||||
|
||||
if (!hasCache) {
|
||||
setAlertsLoading(true);
|
||||
setLovedOnesLoading(true);
|
||||
setActivitiesLoading(true);
|
||||
}
|
||||
setAlertsError(null);
|
||||
setLovedOnesError(null);
|
||||
setActivitiesError(null);
|
||||
|
||||
try {
|
||||
const nextSnapshot = await fetchHomeSnapshot(
|
||||
activeUserId,
|
||||
getRecipientAvatarUrl
|
||||
);
|
||||
const hydratedSnapshot = await hydrateHomeSnapshotImages(
|
||||
activeUserId,
|
||||
nextSnapshot
|
||||
);
|
||||
const nextSnapshotHash = buildSnapshotHash(hydratedSnapshot);
|
||||
|
||||
if (lastSnapshotHashRef.current !== nextSnapshotHash) {
|
||||
setMonthlySentTotal(hydratedSnapshot.monthlySentTotal);
|
||||
setAlerts(hydratedSnapshot.alerts);
|
||||
setLovedOnes(hydratedSnapshot.lovedOnes);
|
||||
setActivities(hydratedSnapshot.activities);
|
||||
lastSnapshotHashRef.current = nextSnapshotHash;
|
||||
}
|
||||
|
||||
setAlertsLoading(false);
|
||||
setLovedOnesLoading(false);
|
||||
setActivitiesLoading(false);
|
||||
|
||||
void writeCache(cacheKey, {
|
||||
userId: activeUserId,
|
||||
monthlySentTotal: hydratedSnapshot.monthlySentTotal,
|
||||
alerts: hydratedSnapshot.alerts,
|
||||
lovedOnes: hydratedSnapshot.lovedOnes,
|
||||
activities: hydratedSnapshot.activities,
|
||||
imageUrls: hydratedSnapshot.imageUrls,
|
||||
cachedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[home data fetch]', err);
|
||||
setAlertsError('Could not load care alerts');
|
||||
setLovedOnesError('Could not load loved ones');
|
||||
setActivitiesError('Could not load activity');
|
||||
setAlertsLoading(false);
|
||||
setLovedOnesLoading(false);
|
||||
setActivitiesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAlertTap = (alert: HomeAlert) => {
|
||||
history.push('/support/new', {
|
||||
recipientId: alert.recipientId,
|
||||
serviceType: alert.serviceType,
|
||||
parentRoot: '/home',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSupportClick = (serviceType?: SupportCategoryTone) => {
|
||||
history.push(
|
||||
'/support/new',
|
||||
serviceType
|
||||
? { serviceType, parentRoot: '/home' }
|
||||
: { parentRoot: '/home' }
|
||||
);
|
||||
};
|
||||
|
||||
const handleRecipientsClick = () => {
|
||||
history.push('/recipients');
|
||||
};
|
||||
|
||||
const handleTogglePinLovedOne = async () => {
|
||||
if (!selectedLovedOne?.id || !user?.id || lovedOneActionLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isPinned = Boolean(selectedLovedOne.pinnedAt);
|
||||
setLovedOneActionLoading(true);
|
||||
|
||||
const nextLovedOnes = lovedOnes.map((person) => {
|
||||
if (person.id !== selectedLovedOne.id) return person;
|
||||
return {
|
||||
...person,
|
||||
pinnedAt: isPinned ? null : new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
const sortedLovedOnes = [...nextLovedOnes].sort((a, b) => {
|
||||
if (a.pinnedAt && !b.pinnedAt) return -1;
|
||||
if (!a.pinnedAt && b.pinnedAt) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
setLovedOnes(sortedLovedOnes);
|
||||
setSelectedLovedOne(null);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('recipients')
|
||||
.update({ pinned_at: isPinned ? null : new Date().toISOString() })
|
||||
.eq('id', selectedLovedOne.id)
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (error) {
|
||||
await loadHomePageData(user.id);
|
||||
}
|
||||
|
||||
setLovedOneActionLoading(false);
|
||||
};
|
||||
|
||||
const handleDeleteLovedOne = async () => {
|
||||
if (!selectedLovedOne?.id || !user?.id || lovedOneActionLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLovedOneActionLoading(true);
|
||||
const recipientId = selectedLovedOne.id;
|
||||
const previousLovedOnes = lovedOnes;
|
||||
|
||||
setLovedOnes((current) =>
|
||||
current.filter((person) => person.id !== recipientId)
|
||||
);
|
||||
setSelectedLovedOne(null);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('recipients')
|
||||
.update({ archived_at: new Date().toISOString(), is_active: false })
|
||||
.eq('id', recipientId)
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (error) {
|
||||
setLovedOnes(previousLovedOnes);
|
||||
}
|
||||
|
||||
setLovedOneActionLoading(false);
|
||||
};
|
||||
|
||||
const displayName = useMemo(() => {
|
||||
const firstName = profile?.first_name?.trim();
|
||||
if (firstName) return firstName;
|
||||
|
||||
const emailName = user?.email?.split('@')[0]?.trim();
|
||||
return emailName || 'there';
|
||||
}, [profile?.first_name, user?.email]);
|
||||
|
||||
const displayInitial = displayName.charAt(0).toUpperCase();
|
||||
const activeLovedOnesCount = lovedOnes.length;
|
||||
const isPreviewMode = !user;
|
||||
const currentMonthLabel = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
month: 'long',
|
||||
}).format(new Date()),
|
||||
[]
|
||||
);
|
||||
const monthlySentLabel = useMemo(
|
||||
() => formatMoney(monthlySentTotal, 'USD'),
|
||||
[monthlySentTotal]
|
||||
);
|
||||
const showInitialDashboardSkeleton =
|
||||
!isPreviewMode &&
|
||||
profileStatus === 'loading' &&
|
||||
lovedOnesLoading &&
|
||||
activitiesLoading &&
|
||||
alertsLoading &&
|
||||
lovedOnes.length === 0 &&
|
||||
activities.length === 0 &&
|
||||
alerts.length === 0;
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent
|
||||
fullscreen
|
||||
style={{ '--background': 'var(--color-bg)' } as React.CSSProperties}
|
||||
>
|
||||
{showInitialDashboardSkeleton ? (
|
||||
<DashboardSkeleton />
|
||||
) : (
|
||||
<div className="home-shell">
|
||||
<div className="home-topline">
|
||||
<div className="home-topline-left">
|
||||
<button
|
||||
type="button"
|
||||
className="home-main-avatar-wrap"
|
||||
aria-label="Open profile"
|
||||
onClick={() => history.push('/profile')}
|
||||
>
|
||||
{profileAvatarUrl ? (
|
||||
<img
|
||||
src={profileAvatarUrl}
|
||||
alt={profile?.full_name || displayName}
|
||||
className="home-main-avatar"
|
||||
/>
|
||||
) : (
|
||||
<span className="home-main-avatar-fallback">
|
||||
{displayInitial}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="home-greeting-block">
|
||||
<h1 className="home-greeting">Hi {displayName} 👋</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="home-notification-card"
|
||||
aria-label="Notifications"
|
||||
onClick={() =>
|
||||
history.push('/notifications', { parentRoot: '/home' })
|
||||
}
|
||||
>
|
||||
<IonIcon icon={notificationsOutline} />
|
||||
<span className="home-dot" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="home-header-actions-row">
|
||||
<div className="home-tagline-wrap">
|
||||
<p className="home-subcopy">
|
||||
Supporting {activeLovedOnesCount}{' '}
|
||||
{activeLovedOnesCount === 1 ? 'loved one' : 'loved ones'} ❤️
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-month-pill">
|
||||
<div className="home-month-icon">
|
||||
<IonIcon icon={heart} />
|
||||
</div>
|
||||
<div className="home-month-copy">
|
||||
<p className="home-month-label">{currentMonthLabel}</p>
|
||||
<p className="home-month-value">{monthlySentLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(alertsLoading || alertsError || alerts.length > 0) && (
|
||||
<>
|
||||
<div className="home-section-row">
|
||||
<h2 className="home-section-heading">Care Alerts</h2>
|
||||
</div>
|
||||
|
||||
<div className="home-alert-marquee">
|
||||
{alertsLoading && alerts.length === 0 ? (
|
||||
<div className="home-alert-overlay-state">
|
||||
<div
|
||||
className="home-alert-card home-alert-skeleton"
|
||||
aria-label="Loading care alerts"
|
||||
>
|
||||
<div className="home-alert-icon" />
|
||||
<div className="home-alert-content">
|
||||
<span className="home-alert-skeleton-title" />
|
||||
<span className="home-alert-skeleton-subtitle" />
|
||||
<span className="home-alert-skeleton-cta" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : alertsError ? (
|
||||
<div className="home-alert-overlay-state">
|
||||
<p className="home-alert-error">{alertsError}</p>
|
||||
</div>
|
||||
) : alerts.length > 0 ? (
|
||||
<div className="home-alert-marquee-track">
|
||||
{[...alerts, ...alerts].map((alert, index) => (
|
||||
<button
|
||||
key={`${alert.id}-${index}`}
|
||||
type="button"
|
||||
className="home-alert-card"
|
||||
onClick={() => handleAlertTap(alert)}
|
||||
>
|
||||
<div
|
||||
className={`home-alert-icon home-tone-${alert.tone}`}
|
||||
>
|
||||
{alert.iconType === 'image' ? (
|
||||
<img
|
||||
src={alert.icon as string}
|
||||
alt=""
|
||||
className="home-custom-icon is-basket"
|
||||
/>
|
||||
) : (
|
||||
<IonIcon icon={alert.icon as any} />
|
||||
)}
|
||||
</div>
|
||||
<div className="home-alert-content">
|
||||
<span className="home-alert-title">
|
||||
{alert.title}
|
||||
</span>
|
||||
<span className="home-alert-subtitle">
|
||||
{alert.subtitle}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`home-alert-chevron-chip home-tone-${alert.tone}`}
|
||||
>
|
||||
<IonIcon icon={chevronForwardOutline} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="home-section-row home-section-row-spaced">
|
||||
<h2 className="home-section-heading">Your Loved Ones</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="home-section-link"
|
||||
onClick={handleRecipientsClick}
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="home-loved-ones-scroll">
|
||||
{lovedOnesLoading && lovedOnes.length === 0 ? (
|
||||
<div
|
||||
className="home-person-card home-person-card-lavender"
|
||||
style={{ opacity: 0.5 }}
|
||||
>
|
||||
<div className="home-person-header">
|
||||
<div className="home-person-meta">
|
||||
<div className="home-person-avatar-shell"></div>
|
||||
<div>
|
||||
<p className="home-person-name">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : lovedOnesError ? (
|
||||
<p className="home-alert-error">{lovedOnesError}</p>
|
||||
) : lovedOnes.length === 0 ? (
|
||||
<div
|
||||
className="home-empty-alerts"
|
||||
style={{ margin: '0 20px', width: 'auto' }}
|
||||
>
|
||||
<div className="home-empty-alerts-icon">
|
||||
<IonIcon icon={heart} />
|
||||
</div>
|
||||
<h3 className="home-empty-alerts-msg">No loved ones yet</h3>
|
||||
<p className="home-empty-alerts-helper">
|
||||
Add family members to start supporting them.
|
||||
</p>
|
||||
<button
|
||||
className="home-empty-alerts-cta"
|
||||
onClick={handleRecipientsClick}
|
||||
>
|
||||
Add loved one
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
lovedOnes.map((person) => (
|
||||
<div
|
||||
key={person.id}
|
||||
className={`home-person-card home-person-card-${person.cardTone}`}
|
||||
>
|
||||
<div className="home-person-header">
|
||||
<div className="home-person-meta">
|
||||
<div className="home-person-avatar-shell">
|
||||
{person.avatar ? (
|
||||
<img
|
||||
src={person.avatar}
|
||||
alt={person.name}
|
||||
className="home-person-avatar"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
/>
|
||||
) : (
|
||||
<span className="home-person-initial">
|
||||
{person.fallbackInitial}
|
||||
</span>
|
||||
)}
|
||||
<span className="home-person-online" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="home-person-name">
|
||||
{person.name} <span>{person.emoji}</span>
|
||||
</p>
|
||||
<p className="home-person-location">
|
||||
{person.lastSupportLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="home-card-menu-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className={`home-card-menu ${selectedLovedOne?.id === person.id ? 'is-active' : ''}`}
|
||||
aria-label={`More options for ${person.name}`}
|
||||
aria-expanded={selectedLovedOne?.id === person.id}
|
||||
onClick={() =>
|
||||
setSelectedLovedOne((current) =>
|
||||
current?.id === person.id ? null : person
|
||||
)
|
||||
}
|
||||
>
|
||||
<IonIcon icon={ellipsisVertical} />
|
||||
</button>
|
||||
{selectedLovedOne?.id === person.id && (
|
||||
<div className="home-loved-one-panel">
|
||||
<button
|
||||
type="button"
|
||||
className="home-loved-one-panel-action"
|
||||
onClick={() => {
|
||||
void handleTogglePinLovedOne();
|
||||
}}
|
||||
disabled={lovedOneActionLoading}
|
||||
>
|
||||
<IonIcon
|
||||
icon={person.pinnedAt ? attachOutline : attach}
|
||||
/>
|
||||
<span>{person.pinnedAt ? 'Unpin' : 'Pin'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="home-loved-one-panel-action is-danger"
|
||||
onClick={() => {
|
||||
void handleDeleteLovedOne();
|
||||
}}
|
||||
disabled={lovedOneActionLoading}
|
||||
>
|
||||
<IonIcon icon={trashOutline} />
|
||||
<span>Remove</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="home-status-stack">
|
||||
{person.statuses.length > 0 ? (
|
||||
person.statuses.slice(0, 3).map((item: any) => {
|
||||
const isSuccess = item.status === 'success';
|
||||
return (
|
||||
<div
|
||||
key={`${person.id}-${item.id ?? item.createdAt ?? item.label}`}
|
||||
className="home-status-row"
|
||||
>
|
||||
<div
|
||||
className={`home-status-icon home-tone-${item.iconTone}`}
|
||||
>
|
||||
{renderHomeIcon(item)}
|
||||
</div>
|
||||
<div className="home-status-copy">
|
||||
<p className="home-status-label">
|
||||
{item.label}
|
||||
</p>
|
||||
<p
|
||||
className={`home-status-detail ${isSuccess ? 'is-success' : 'is-warning'}`}
|
||||
>
|
||||
{item.detail}
|
||||
</p>
|
||||
</div>
|
||||
<IonIcon
|
||||
icon={
|
||||
isSuccess
|
||||
? checkmarkCircleOutline
|
||||
: timeOutline
|
||||
}
|
||||
className={`home-status-trailing ${isSuccess ? 'is-success' : 'is-warning'}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="home-status-empty-row">
|
||||
<IonIcon icon={heart} />
|
||||
<span>No support sent yet</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`home-send-button home-tone-${person.repeatAction.tone}`}
|
||||
onClick={() =>
|
||||
history.push('/support/new', {
|
||||
recipientId: person.id,
|
||||
serviceType: person.primaryServiceType,
|
||||
parentRoot: '/home',
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="home-send-button-icon">
|
||||
{renderHomeIcon(person.repeatAction)}
|
||||
</span>
|
||||
<span>{person.repeatCta}</span>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="home-carousel-indicator">
|
||||
<span className="home-dot-active" />
|
||||
<span className="home-dot-inactive" />
|
||||
<span className="home-dot-inactive" />
|
||||
</div>
|
||||
|
||||
<div className="home-section-row home-section-row-spaced">
|
||||
<h2 className="home-section-heading">Send Support</h2>
|
||||
</div>
|
||||
|
||||
<div className="home-action-grid">
|
||||
{quickActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
className="home-action-card"
|
||||
onClick={() => handleSupportClick(action.tone)}
|
||||
>
|
||||
<div className={`home-action-icon home-tone-${action.tone}`}>
|
||||
{renderHomeIcon(action)}
|
||||
</div>
|
||||
<div className="home-action-text-block">
|
||||
<span className="home-action-label">{action.label}</span>
|
||||
<span className="home-action-helper">{action.helper}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="home-section-row">
|
||||
<h2 className="home-section-heading">Family Updates</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="home-section-link"
|
||||
onClick={() => history.push('/activity')}
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="home-activity-card">
|
||||
{activitiesLoading && activities.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--ion-color-medium)',
|
||||
}}
|
||||
>
|
||||
Loading activity...
|
||||
</div>
|
||||
) : activitiesError ? (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<p className="home-alert-error">{activitiesError}</p>
|
||||
</div>
|
||||
) : activities.length === 0 ? (
|
||||
<div
|
||||
className="home-empty-alerts"
|
||||
style={{ boxShadow: 'none' }}
|
||||
>
|
||||
<div
|
||||
className="home-empty-alerts-icon"
|
||||
style={{
|
||||
background: 'rgba(23, 24, 39, 0.04)',
|
||||
color: 'var(--ion-color-medium)',
|
||||
}}
|
||||
>
|
||||
<IonIcon icon={timeOutline} />
|
||||
</div>
|
||||
<h3 className="home-empty-alerts-msg">No recent activity</h3>
|
||||
<p className="home-empty-alerts-helper">
|
||||
Support history will appear here once you send support.
|
||||
</p>
|
||||
<button
|
||||
className="home-empty-alerts-cta"
|
||||
onClick={() => handleSupportClick()}
|
||||
>
|
||||
Send first support
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
activities.map((activity, index) => (
|
||||
<button
|
||||
key={activity.id}
|
||||
type="button"
|
||||
className={`home-activity-row home-activity-row-button ${
|
||||
index === activities.length - 1 ? 'is-last' : ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
history.push(`/recipients/${activity.recipientId}`)
|
||||
}
|
||||
>
|
||||
<div className="home-activity-avatar-shell">
|
||||
{activity.avatar ? (
|
||||
<img
|
||||
src={activity.avatar}
|
||||
alt=""
|
||||
className="home-activity-avatar"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
/>
|
||||
) : (
|
||||
<div className="home-activity-avatar home-activity-avatar-fallback">
|
||||
<span>{activity.fallbackInitial}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`home-activity-avatar-badge home-tone-${activity.iconTone}`}
|
||||
>
|
||||
{renderHomeIcon(activity.icon)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="home-activity-copy">
|
||||
<div className="home-activity-head-row">
|
||||
<div className="home-activity-text-block">
|
||||
<p className="home-activity-title">
|
||||
{activity.title}
|
||||
</p>
|
||||
<p className="home-activity-subtitle">
|
||||
{activity.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="home-activity-right">
|
||||
<p className="home-activity-amount">
|
||||
{formatMoney(Number(activity.amount), 'USD')}
|
||||
</p>
|
||||
<div
|
||||
className={`home-activity-badge home-badge-${activity.tone}`}
|
||||
>
|
||||
<span>{activity.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
@@ -1,494 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
IonActionSheet,
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonPage,
|
||||
IonSkeletonText,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
chevronBackOutline,
|
||||
chevronForwardOutline,
|
||||
ellipsisHorizontal,
|
||||
flashOutline,
|
||||
medkitOutline,
|
||||
notificationsOutline,
|
||||
phonePortraitOutline,
|
||||
receiptOutline,
|
||||
trashOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import basketIcon from '../assets/basket.png';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import '../styles/activity.css';
|
||||
import '../styles/recipients.css';
|
||||
|
||||
type ServiceType =
|
||||
| 'grocery'
|
||||
| 'medication'
|
||||
| 'airtime'
|
||||
| 'electricity'
|
||||
| 'support';
|
||||
|
||||
type NotificationRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
priority: string;
|
||||
type: string;
|
||||
read_at: string | null;
|
||||
created_at: string;
|
||||
order_id: string | null;
|
||||
voucher_id: string | null;
|
||||
support_orders: { service_type: string | null } | null;
|
||||
vouchers: { voucher_type: string | null } | null;
|
||||
};
|
||||
|
||||
const previewUserId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
const notificationSelect =
|
||||
'id,title,body,priority,type,read_at,created_at,order_id,voucher_id';
|
||||
|
||||
const normalizeServiceType = (value?: string | null): ServiceType => {
|
||||
const normalized = value?.toLowerCase() ?? '';
|
||||
if (normalized.includes('med')) return 'medication';
|
||||
if (normalized.includes('air') || normalized.includes('data'))
|
||||
return 'airtime';
|
||||
if (normalized.includes('electric')) return 'electricity';
|
||||
if (normalized.includes('grocery') || normalized.includes('voucher')) {
|
||||
return 'grocery';
|
||||
}
|
||||
return 'support';
|
||||
};
|
||||
|
||||
const inferServiceType = (notification: NotificationRow): ServiceType => {
|
||||
const joinedType = notification.support_orders?.service_type;
|
||||
if (joinedType) return normalizeServiceType(joinedType);
|
||||
|
||||
const voucherType = notification.vouchers?.voucher_type;
|
||||
if (voucherType) return normalizeServiceType(voucherType);
|
||||
|
||||
return normalizeServiceType(
|
||||
`${notification.title} ${notification.body} ${notification.type}`
|
||||
);
|
||||
};
|
||||
|
||||
const getServicePresentation = (serviceType: ServiceType) => {
|
||||
if (serviceType === 'medication') {
|
||||
return {
|
||||
className: 'notification-service-medication',
|
||||
icon: medkitOutline,
|
||||
};
|
||||
}
|
||||
|
||||
if (serviceType === 'airtime') {
|
||||
return {
|
||||
className: 'notification-service-airtime',
|
||||
icon: phonePortraitOutline,
|
||||
};
|
||||
}
|
||||
|
||||
if (serviceType === 'electricity') {
|
||||
return {
|
||||
className: 'notification-service-electricity',
|
||||
icon: flashOutline,
|
||||
};
|
||||
}
|
||||
|
||||
if (serviceType === 'grocery') {
|
||||
return {
|
||||
className: 'notification-service-grocery',
|
||||
iconImageSrc: basketIcon,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
className: 'notification-service-support',
|
||||
icon: receiptOutline,
|
||||
};
|
||||
};
|
||||
|
||||
const NotificationsPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user } = useAuth();
|
||||
const isPreviewMode = !user;
|
||||
const [notifications, setNotifications] = useState<NotificationRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
|
||||
const showError = useCallback((message: string) => {
|
||||
setError(message);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
}, []);
|
||||
|
||||
const loadNotifications = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const activeUserId = user?.id ?? previewUserId;
|
||||
let { data, error: loadError } = await supabase
|
||||
.from('notifications')
|
||||
.select(notificationSelect)
|
||||
.eq('user_id', activeUserId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!loadError && user?.id && (data ?? []).length === 0) {
|
||||
const previewResult = await supabase
|
||||
.from('notifications')
|
||||
.select(notificationSelect)
|
||||
.eq('user_id', previewUserId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
data = previewResult.data;
|
||||
loadError = previewResult.error;
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
showError(loadError.message);
|
||||
setNotifications([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseNotifications = (data ?? []) as unknown as NotificationRow[];
|
||||
const orderIds = Array.from(
|
||||
new Set(
|
||||
baseNotifications
|
||||
.map((notification) => notification.order_id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
)
|
||||
);
|
||||
const voucherIds = Array.from(
|
||||
new Set(
|
||||
baseNotifications
|
||||
.map((notification) => notification.voucher_id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
)
|
||||
);
|
||||
|
||||
const [ordersResult, vouchersResult] = await Promise.all([
|
||||
orderIds.length > 0
|
||||
? supabase
|
||||
.from('support_orders')
|
||||
.select('id,service_type')
|
||||
.in('id', orderIds)
|
||||
: Promise.resolve({ data: [], error: null }),
|
||||
voucherIds.length > 0
|
||||
? supabase
|
||||
.from('vouchers')
|
||||
.select('id,voucher_type')
|
||||
.in('id', voucherIds)
|
||||
: Promise.resolve({ data: [], error: null }),
|
||||
]);
|
||||
|
||||
if (ordersResult.error || vouchersResult.error) {
|
||||
console.warn(
|
||||
'[NotificationsPage] Notification detail lookup failed',
|
||||
ordersResult.error ?? vouchersResult.error
|
||||
);
|
||||
}
|
||||
|
||||
const serviceTypesByOrderId = new Map(
|
||||
(
|
||||
(ordersResult.data ?? []) as {
|
||||
id: string;
|
||||
service_type: string | null;
|
||||
}[]
|
||||
).map((order) => [order.id, order.service_type])
|
||||
);
|
||||
const voucherTypesById = new Map(
|
||||
(
|
||||
(vouchersResult.data ?? []) as {
|
||||
id: string;
|
||||
voucher_type: string | null;
|
||||
}[]
|
||||
).map((voucher) => [voucher.id, voucher.voucher_type])
|
||||
);
|
||||
|
||||
setNotifications(
|
||||
baseNotifications.map((notification) => ({
|
||||
...notification,
|
||||
support_orders: notification.order_id
|
||||
? {
|
||||
service_type:
|
||||
serviceTypesByOrderId.get(notification.order_id) ?? null,
|
||||
}
|
||||
: null,
|
||||
vouchers: notification.voucher_id
|
||||
? {
|
||||
voucher_type:
|
||||
voucherTypesById.get(notification.voucher_id) ?? null,
|
||||
}
|
||||
: null,
|
||||
}))
|
||||
);
|
||||
} catch (loadCrash) {
|
||||
console.error(
|
||||
'[NotificationsPage] Failed to load notifications',
|
||||
loadCrash
|
||||
);
|
||||
showError('Notifications could not be loaded. Pull back and try again.');
|
||||
setNotifications([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [showError, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadNotifications();
|
||||
}, [loadNotifications]);
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
void loadNotifications();
|
||||
});
|
||||
|
||||
const markRead = async (notificationId: string) => {
|
||||
const readAt = new Date().toISOString();
|
||||
setNotifications((current) =>
|
||||
current.map((item) =>
|
||||
item.id === notificationId ? { ...item, read_at: readAt } : item
|
||||
)
|
||||
);
|
||||
|
||||
if (isPreviewMode) {
|
||||
const { error: updateError } = await supabase
|
||||
.from('notifications')
|
||||
.update({ read_at: readAt })
|
||||
.eq('id', notificationId)
|
||||
.eq('user_id', previewUserId);
|
||||
|
||||
if (updateError) {
|
||||
showError(updateError.message);
|
||||
void loadNotifications();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: invokeError } = await supabase.functions.invoke(
|
||||
'mark-notification-read',
|
||||
{
|
||||
body: { notificationId },
|
||||
}
|
||||
);
|
||||
if (invokeError) {
|
||||
showError(invokeError.message);
|
||||
void loadNotifications();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenNotification = async (notification: NotificationRow) => {
|
||||
if (!notification.read_at) {
|
||||
await markRead(notification.id);
|
||||
}
|
||||
if (notification.order_id) {
|
||||
history.push(`/orders/${notification.order_id}`, {
|
||||
parentRoot: '/profile',
|
||||
});
|
||||
} else if (notification.voucher_id) {
|
||||
history.push(`/voucher/${notification.voucher_id}`, {
|
||||
parentRoot: '/profile',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearNotifications = async () => {
|
||||
const targetUserId = user?.id ?? previewUserId;
|
||||
|
||||
const { error: deleteError } = await supabase
|
||||
.from('notifications')
|
||||
.delete()
|
||||
.eq('user_id', targetUserId);
|
||||
|
||||
if (deleteError) {
|
||||
showError(deleteError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setNotifications([]);
|
||||
setShowActions(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage style={{ backgroundColor: '#fafafa' }}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar
|
||||
style={
|
||||
{
|
||||
'--background': '#fafafa',
|
||||
'--border-width': '0px',
|
||||
'--padding-top': 'calc(var(--ion-safe-area-top, 0px) + 8px)',
|
||||
'--min-height': 'calc(64px + var(--ion-safe-area-top, 0px))',
|
||||
'--padding-start': '16px',
|
||||
'--padding-end': '16px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<IonButtons slot="start">
|
||||
<IonButton
|
||||
fill="clear"
|
||||
onClick={() =>
|
||||
history.length > 1
|
||||
? history.goBack()
|
||||
: history.replace('/profile')
|
||||
}
|
||||
aria-label="Go back"
|
||||
style={
|
||||
{
|
||||
'--color': 'var(--ion-color-primary)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle style={{ fontSize: 18, fontWeight: 700 }}>
|
||||
Notifications
|
||||
</IonTitle>
|
||||
<IonButtons slot="end">
|
||||
<IonButton
|
||||
fill="clear"
|
||||
onClick={() => setShowActions(true)}
|
||||
aria-label="Notification actions"
|
||||
disabled={loading || notifications.length === 0}
|
||||
style={
|
||||
{
|
||||
'--color': 'var(--ion-color-primary)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<IonIcon icon={ellipsisHorizontal} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent
|
||||
style={
|
||||
{
|
||||
'--background': '#fafafa',
|
||||
'--padding-top': '8px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<IonActionSheet
|
||||
isOpen={showActions}
|
||||
onDidDismiss={() => setShowActions(false)}
|
||||
header="Notifications"
|
||||
cssClass="app-action-sheet"
|
||||
buttons={[
|
||||
{
|
||||
text: 'Clear notifications',
|
||||
role: 'destructive',
|
||||
icon: trashOutline,
|
||||
handler: () => {
|
||||
void handleClearNotifications();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{error && (
|
||||
<p style={{ margin: '12px 20px', color: '#dc2626', fontSize: 13 }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="notifications-list-card">
|
||||
{[1, 2, 3].map((item) => (
|
||||
<div className="notification-row" key={item}>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '65%', height: 15 }}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '90%', height: 12 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="empty-state-card" style={{ marginTop: 20 }}>
|
||||
<IonIcon icon={notificationsOutline} className="esc-icon" />
|
||||
<h2 className="esc-title">No notifications yet</h2>
|
||||
<p className="esc-msg">
|
||||
Important payment and voucher updates will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="notifications-list-card">
|
||||
{notifications.map((notification) => {
|
||||
const service = getServicePresentation(
|
||||
inferServiceType(notification)
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={notification.id}
|
||||
type="button"
|
||||
className="notification-row"
|
||||
onClick={() => handleOpenNotification(notification)}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`nr-status-dot ${notification.read_at ? 'is-read' : 'is-unread'}`}
|
||||
/>
|
||||
<div
|
||||
className={`nr-icon-box ${service.className} ${notification.read_at ? 'is-read' : ''}`}
|
||||
>
|
||||
{service.iconImageSrc ? (
|
||||
<img src={service.iconImageSrc} alt="" />
|
||||
) : (
|
||||
<IonIcon icon={service.icon} />
|
||||
)}
|
||||
</div>
|
||||
<div className="nr-content">
|
||||
<h3 className="nr-title">{notification.title}</h3>
|
||||
<p className="nr-body">{notification.body}</p>
|
||||
<span className="nr-time">
|
||||
{new Date(notification.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<IonIcon
|
||||
icon={chevronForwardOutline}
|
||||
className="nr-link-icon"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsPage;
|
||||
@@ -1,253 +0,0 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
IonContent,
|
||||
IonIcon,
|
||||
IonPage,
|
||||
IonRefresher,
|
||||
IonRefresherContent,
|
||||
IonSkeletonText,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { receiptOutline } from 'ionicons/icons';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ListSearchRow from '../components/ListSearchRow';
|
||||
import OrderSummaryCard, { OrderSummary } from '../components/OrderSummaryCard';
|
||||
import { formatMoney } from '../utils/formatMoney';
|
||||
import '../styles/support.css';
|
||||
import '../styles/recipients.css';
|
||||
|
||||
type OrderRow = {
|
||||
id: string;
|
||||
service_type: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
recipient_id: string;
|
||||
merchant_id: string | null;
|
||||
recipients?: {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
photo_path: string | null;
|
||||
} | null;
|
||||
merchants?: { name: string; branch_name: string | null } | null;
|
||||
};
|
||||
|
||||
const statusFilters = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'paid', label: 'Paid' },
|
||||
{ value: 'ready_for_redemption', label: 'Ready' },
|
||||
{ value: 'redeemed', label: 'Redeemed' },
|
||||
{ value: 'delivered', label: 'Delivered' },
|
||||
{ value: 'expired', label: 'Expired' },
|
||||
];
|
||||
|
||||
const OrdersPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user } = useAuth();
|
||||
const [orders, setOrders] = useState<OrderSummary[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeStatus, setActiveStatus] = useState('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
void loadOrders();
|
||||
});
|
||||
|
||||
const showError = (message: string) => {
|
||||
setError(message);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
const loadOrders = async () => {
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { data, error: ordersError } = await supabase
|
||||
.from('support_orders')
|
||||
.select(
|
||||
'id,service_type,amount,status,created_at,recipient_id,merchant_id,recipients(first_name,last_name,photo_path),merchants(name,branch_name)'
|
||||
)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (ordersError) {
|
||||
showError(ordersError.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = (data ?? []) as unknown as OrderRow[];
|
||||
const normalized = await Promise.all(
|
||||
rows.map(async (row) => {
|
||||
const recipientName = row.recipients
|
||||
? `${row.recipients.first_name} ${row.recipients.last_name}`
|
||||
: 'Loved one';
|
||||
const avatarUrl = row.recipients?.photo_path
|
||||
? ((
|
||||
await supabase.storage
|
||||
.from('recipient-photos')
|
||||
.createSignedUrl(row.recipients.photo_path, 3600)
|
||||
).data?.signedUrl ?? null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
recipientName,
|
||||
serviceType:
|
||||
row.service_type.charAt(0).toUpperCase() +
|
||||
row.service_type.slice(1),
|
||||
merchantName: row.merchants
|
||||
? `${row.merchants.name}${row.merchants.branch_name ? ` — ${row.merchants.branch_name}` : ''}`
|
||||
: undefined,
|
||||
amount: Number(row.amount ?? 0),
|
||||
amountLabel: formatMoney(Number(row.amount ?? 0)),
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
avatarUrl,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
setOrders(normalized);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const visibleOrders = useMemo(() => {
|
||||
const query = searchTerm.trim().toLowerCase();
|
||||
return orders.filter((order) => {
|
||||
const statusMatch =
|
||||
activeStatus === 'all' || order.status === activeStatus;
|
||||
const searchMatch =
|
||||
!query ||
|
||||
[
|
||||
order.recipientName,
|
||||
order.serviceType,
|
||||
order.merchantName,
|
||||
order.status,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
return statusMatch && searchMatch;
|
||||
});
|
||||
}, [orders, activeStatus, searchTerm]);
|
||||
|
||||
const handleRefresh = async (event: CustomEvent) => {
|
||||
await loadOrders();
|
||||
event.detail.complete();
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage style={{ backgroundColor: '#fafafa' }}>
|
||||
<IonContent
|
||||
fullscreen
|
||||
style={
|
||||
{
|
||||
'--background': 'linear-gradient(180deg, #fafafa 0%, #f4f0ff 100%)',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': '8px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
|
||||
<IonRefresherContent />
|
||||
</IonRefresher>
|
||||
|
||||
<div
|
||||
className="recipients-top-row"
|
||||
style={{ padding: 'calc(16px + var(--ion-safe-area-top)) 0 12px' }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="recipients-page-title">Activity</h1>
|
||||
<p className="rlc-location" style={{ marginTop: 4 }}>
|
||||
{orders.length} support updates
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ListSearchRow
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Search orders..."
|
||||
/>
|
||||
|
||||
<div className="orders-filter-row">
|
||||
{statusFilters.map((filter) => (
|
||||
<button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
className={`status-filter-chip ${activeStatus === filter.value ? 'active' : 'inactive'}`}
|
||||
onClick={() => setActiveStatus(filter.value)}
|
||||
style={{ border: 'none' }}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ margin: '0 20px 12px', color: '#dc2626', fontSize: 13 }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div>
|
||||
{[1, 2, 3].map((item) => (
|
||||
<div key={item} className="order-summary-card">
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 16,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '70%', height: 15 }}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '45%', height: 12 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : visibleOrders.length === 0 ? (
|
||||
<div className="empty-state-card">
|
||||
<IonIcon icon={receiptOutline} className="esc-icon" />
|
||||
<h2 className="esc-title">
|
||||
{orders.length === 0 ? 'No orders yet' : 'No matching orders'}
|
||||
</h2>
|
||||
<p className="esc-msg">
|
||||
{orders.length === 0
|
||||
? 'Your support transactions will appear here.'
|
||||
: 'Try changing your search or status filter.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{visibleOrders.map((order) => (
|
||||
<OrderSummaryCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
onClick={(id) => history.push(`/orders/${id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrdersPage;
|
||||
@@ -1,503 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonContent,
|
||||
IonIcon,
|
||||
IonPage,
|
||||
IonSkeletonText,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
cardOutline,
|
||||
createOutline,
|
||||
documentTextOutline,
|
||||
helpCircleOutline,
|
||||
informationCircleOutline,
|
||||
logoWhatsapp,
|
||||
logOutOutline,
|
||||
mailOutline,
|
||||
notificationsOutline,
|
||||
personOutline,
|
||||
phonePortraitOutline,
|
||||
shieldCheckmarkOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { buildCacheKey, readCache, writeCache } from '../utils/localCache';
|
||||
import SettingsRow from '../components/SettingsRow';
|
||||
import sarahAvatarImage from '../assets/sarah.jpg';
|
||||
import '../styles/profile.css';
|
||||
|
||||
type Profile = {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
full_name: string;
|
||||
phone: string;
|
||||
country_of_residence: string;
|
||||
avatar_path: string | null;
|
||||
notification_push_enabled: boolean;
|
||||
notification_email_enabled: boolean;
|
||||
notification_sms_enabled: boolean;
|
||||
};
|
||||
|
||||
type PreviewProfilePayload = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone: string;
|
||||
country_of_residence: string;
|
||||
avatar_url: string | null;
|
||||
notification_push_enabled: boolean;
|
||||
notification_email_enabled: boolean;
|
||||
notification_sms_enabled: boolean;
|
||||
};
|
||||
|
||||
const PREVIEW_PROFILE_STORAGE_KEY = 'kumusha-preview-profile';
|
||||
|
||||
const getPreviewProfilePayload = (): PreviewProfilePayload => {
|
||||
const fallback: PreviewProfilePayload = {
|
||||
first_name: 'Sarah',
|
||||
last_name: 'Moyo',
|
||||
phone: '+44 7123 456789',
|
||||
country_of_residence: 'United Kingdom',
|
||||
avatar_url: sarahAvatarImage,
|
||||
notification_push_enabled: true,
|
||||
notification_email_enabled: true,
|
||||
notification_sms_enabled: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(PREVIEW_PROFILE_STORAGE_KEY);
|
||||
return saved ? { ...fallback, ...JSON.parse(saved) } : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const savePreviewProfilePayload = (
|
||||
profile: Profile,
|
||||
avatarUrl: string | null
|
||||
) => {
|
||||
const payload: PreviewProfilePayload = {
|
||||
first_name: profile.first_name,
|
||||
last_name: profile.last_name,
|
||||
phone: profile.phone,
|
||||
country_of_residence: profile.country_of_residence,
|
||||
avatar_url: avatarUrl,
|
||||
notification_push_enabled: profile.notification_push_enabled,
|
||||
notification_email_enabled: profile.notification_email_enabled,
|
||||
notification_sms_enabled: profile.notification_sms_enabled,
|
||||
};
|
||||
localStorage.setItem(PREVIEW_PROFILE_STORAGE_KEY, JSON.stringify(payload));
|
||||
};
|
||||
|
||||
const buildPreviewProfile = () => {
|
||||
const payload = getPreviewProfilePayload();
|
||||
const profile: Profile = {
|
||||
id: 'preview-profile',
|
||||
first_name: payload.first_name,
|
||||
last_name: payload.last_name,
|
||||
full_name: `${payload.first_name} ${payload.last_name}`,
|
||||
phone: payload.phone,
|
||||
country_of_residence: payload.country_of_residence,
|
||||
avatar_path: null,
|
||||
notification_push_enabled: payload.notification_push_enabled,
|
||||
notification_email_enabled: payload.notification_email_enabled,
|
||||
notification_sms_enabled: payload.notification_sms_enabled,
|
||||
};
|
||||
|
||||
return { profile, avatarUrl: payload.avatar_url };
|
||||
};
|
||||
|
||||
const profileHash = (profile: Profile, avatarUrl: string | null) =>
|
||||
JSON.stringify({ profile, avatarUrl });
|
||||
|
||||
const ProfilePage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user, profile: authProfile, signOut, refreshProfile } = useAuth();
|
||||
const [profile, setProfile] = useState<Profile | null>(() =>
|
||||
authProfile ? (authProfile as Profile) : null
|
||||
);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(!authProfile);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
void loadProfile();
|
||||
});
|
||||
|
||||
const showError = (message: string) => {
|
||||
setError(message);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
const getPublicAvatarUrl = (avatarPath?: string | null) => {
|
||||
if (!avatarPath) return null;
|
||||
return supabase.storage.from('avatars').getPublicUrl(avatarPath).data
|
||||
.publicUrl;
|
||||
};
|
||||
|
||||
const loadProfile = async () => {
|
||||
if (!user) {
|
||||
const preview = buildPreviewProfile();
|
||||
setProfile(preview.profile);
|
||||
setAvatarUrl(preview.avatarUrl);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const profileCacheKey = buildCacheKey(user.id, 'profilePage');
|
||||
const avatarCacheKey = buildCacheKey(user.id, 'profileAvatarUrl');
|
||||
let hasDisplayProfile = Boolean(profile || authProfile);
|
||||
|
||||
try {
|
||||
const [cachedProfilePage, cachedAvatarUrl] = await Promise.all([
|
||||
readCache<{ profile: Profile; avatarUrl: string | null }>(
|
||||
profileCacheKey
|
||||
),
|
||||
readCache<string>(avatarCacheKey),
|
||||
]);
|
||||
|
||||
if (cachedProfilePage?.profile) {
|
||||
setProfile(cachedProfilePage.profile);
|
||||
setAvatarUrl(cachedProfilePage.avatarUrl ?? cachedAvatarUrl ?? null);
|
||||
hasDisplayProfile = true;
|
||||
} else if (authProfile) {
|
||||
const nextAvatarUrl =
|
||||
cachedAvatarUrl ?? getPublicAvatarUrl(authProfile.avatar_path);
|
||||
setProfile(authProfile as Profile);
|
||||
setAvatarUrl(nextAvatarUrl);
|
||||
hasDisplayProfile = true;
|
||||
} else if (cachedAvatarUrl) {
|
||||
setAvatarUrl(cachedAvatarUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[profile cache] error', err);
|
||||
}
|
||||
|
||||
setLoading(!hasDisplayProfile);
|
||||
|
||||
const { data, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (profileError || !data) {
|
||||
showError(profileError?.message ?? 'Profile not found');
|
||||
if (!hasDisplayProfile) {
|
||||
const preview = buildPreviewProfile();
|
||||
setProfile(preview.profile);
|
||||
setAvatarUrl(preview.avatarUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextProfile = data as Profile;
|
||||
const nextAvatarUrl = getPublicAvatarUrl(nextProfile.avatar_path);
|
||||
const previousHash = profile ? profileHash(profile, avatarUrl) : null;
|
||||
const nextHash = profileHash(nextProfile, nextAvatarUrl);
|
||||
|
||||
if (previousHash !== nextHash) {
|
||||
setProfile(nextProfile);
|
||||
setAvatarUrl(nextAvatarUrl);
|
||||
}
|
||||
|
||||
if (nextAvatarUrl) {
|
||||
void writeCache(avatarCacheKey, nextAvatarUrl);
|
||||
}
|
||||
void writeCache(profileCacheKey, {
|
||||
profile: nextProfile,
|
||||
avatarUrl: nextAvatarUrl,
|
||||
cachedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleTogglePreference = async (
|
||||
field:
|
||||
| 'notification_push_enabled'
|
||||
| 'notification_email_enabled'
|
||||
| 'notification_sms_enabled',
|
||||
checked: boolean
|
||||
) => {
|
||||
if (!profile) return;
|
||||
const previous = profile;
|
||||
const next = { ...profile, [field]: checked };
|
||||
setProfile(next);
|
||||
|
||||
if (!user) {
|
||||
savePreviewProfilePayload(next, avatarUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
const updates: any =
|
||||
field === 'notification_push_enabled'
|
||||
? { notification_push_enabled: checked, updated_at: updatedAt }
|
||||
: field === 'notification_email_enabled'
|
||||
? { notification_email_enabled: checked, updated_at: updatedAt }
|
||||
: { notification_sms_enabled: checked, updated_at: updatedAt };
|
||||
|
||||
if (field === 'notification_push_enabled' && !checked) {
|
||||
updates.fcm_token = null;
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('profiles')
|
||||
.update(updates)
|
||||
.eq('id', profile.id);
|
||||
|
||||
if (updateError) {
|
||||
setProfile(previous);
|
||||
showError(updateError.message);
|
||||
return;
|
||||
}
|
||||
await refreshProfile();
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setSigningOut(true);
|
||||
try {
|
||||
await FirebaseAuthentication.signOut().catch(() => undefined);
|
||||
await signOut();
|
||||
history.replace('/auth');
|
||||
} finally {
|
||||
setSigningOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initials =
|
||||
profile?.full_name
|
||||
?.split(' ')
|
||||
.map((part) => part.charAt(0))
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase() || '?';
|
||||
|
||||
const paymentMethodsCount = 0;
|
||||
|
||||
return (
|
||||
<IonPage style={{ backgroundColor: '#fafafa' }}>
|
||||
<IonContent
|
||||
fullscreen
|
||||
className="profile-shell"
|
||||
style={
|
||||
{
|
||||
'--background': 'linear-gradient(180deg, #fafafa 0%, #f4f0ff 100%)',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': 'calc(var(--ion-safe-area-top, 0px) + 8px)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<h1 className="profile-page-title">Profile</h1>
|
||||
{error && (
|
||||
<p style={{ margin: '0 20px 12px', color: '#dc2626', fontSize: 13 }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="profile-summary-card">
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: 72, height: 72, borderRadius: 24, flexShrink: 0 }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<IonSkeletonText animated style={{ width: '70%', height: 22 }} />
|
||||
<IonSkeletonText animated style={{ width: '90%', height: 14 }} />
|
||||
</div>
|
||||
</div>
|
||||
) : profile ? (
|
||||
<>
|
||||
<div className="profile-summary-card">
|
||||
<div className="psc-avatar-shell">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={profile.full_name}
|
||||
className="psc-avatar"
|
||||
/>
|
||||
) : (
|
||||
<div className="psc-avatar-placeholder">{initials}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="psc-info">
|
||||
<h2 className="psc-name">{profile.full_name}</h2>
|
||||
<p className="psc-location">{profile.country_of_residence}</p>
|
||||
</div>
|
||||
<IonButton
|
||||
className="psc-edit-btn"
|
||||
onClick={() => history.push('/profile/edit')}
|
||||
>
|
||||
<IonIcon icon={createOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</div>
|
||||
|
||||
<h2 className="settings-section-title">Payments</h2>
|
||||
<div className="settings-card">
|
||||
<SettingsRow
|
||||
icon={cardOutline}
|
||||
title="Saved cards"
|
||||
subtitle={
|
||||
paymentMethodsCount > 0
|
||||
? `${paymentMethodsCount} saved for faster checkout`
|
||||
: 'No saved cards yet'
|
||||
}
|
||||
onClick={() =>
|
||||
showError(
|
||||
'Saved cards will be added when payments are connected in a later phase'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="settings-section-title">Account</h2>
|
||||
<div className="settings-card">
|
||||
<SettingsRow
|
||||
icon={personOutline}
|
||||
title="Edit profile"
|
||||
subtitle="Name, phone, country, avatar"
|
||||
onClick={() => history.push('/profile/edit')}
|
||||
/>
|
||||
<SettingsRow
|
||||
icon={notificationsOutline}
|
||||
title="Notifications"
|
||||
subtitle="Open your notification inbox"
|
||||
onClick={() => history.push('/notifications')}
|
||||
/>
|
||||
<SettingsRow
|
||||
icon={shieldCheckmarkOutline}
|
||||
title="Security settings"
|
||||
subtitle="Password and account safety"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
showError(
|
||||
'Security settings will be available after MVP launch'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="settings-section-title">Support</h2>
|
||||
<div className="settings-card">
|
||||
<SettingsRow
|
||||
icon={helpCircleOutline}
|
||||
title="Help centre"
|
||||
subtitle="Get answers about vouchers and support"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
showError(
|
||||
'Help centre articles will be added in a later phase'
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
icon={logoWhatsapp}
|
||||
iconColor="#6d28d9"
|
||||
iconBg="rgba(109,40,217,0.1)"
|
||||
title="Contact support"
|
||||
subtitle="Talk to Kumusha if something goes wrong"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
showError('Support chat will be connected in a later phase')
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
icon={informationCircleOutline}
|
||||
title="How Kumusha works"
|
||||
subtitle="Learn how vouchers, delivery and redemption work"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
showError(
|
||||
'Guided product explainers will be added in a later phase'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="settings-section-title">Legal</h2>
|
||||
<div className="settings-card">
|
||||
<SettingsRow
|
||||
icon={documentTextOutline}
|
||||
title="Terms of service"
|
||||
subtitle="Read the rules for using Kumusha"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
showError('Terms of service will be added in a later phase')
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
icon={shieldCheckmarkOutline}
|
||||
title="Privacy policy"
|
||||
subtitle="See how your account and recipient data is handled"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
showError('Privacy policy will be added in a later phase')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="settings-section-title">Preferences</h2>
|
||||
<div className="settings-card">
|
||||
<SettingsRow
|
||||
icon={notificationsOutline}
|
||||
title="Push notifications"
|
||||
type="toggle"
|
||||
checked={profile.notification_push_enabled}
|
||||
onToggle={(checked) =>
|
||||
handleTogglePreference('notification_push_enabled', checked)
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
icon={mailOutline}
|
||||
title="Email updates"
|
||||
type="toggle"
|
||||
checked={profile.notification_email_enabled}
|
||||
onToggle={(checked) =>
|
||||
handleTogglePreference('notification_email_enabled', checked)
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
icon={phonePortraitOutline}
|
||||
title="SMS updates"
|
||||
type="toggle"
|
||||
checked={profile.notification_sms_enabled}
|
||||
onToggle={(checked) =>
|
||||
handleTogglePreference('notification_sms_enabled', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-card">
|
||||
<SettingsRow
|
||||
icon={logOutOutline}
|
||||
title={signingOut ? 'Signing out...' : 'Sign out'}
|
||||
type="button"
|
||||
destructive
|
||||
onClick={handleSignOut}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="profile-footer-mark">
|
||||
<div className="profile-footer-logo">K</div>
|
||||
<p className="profile-footer-name">Kumusha</p>
|
||||
<p className="profile-footer-meta">Version 1.0.0</p>
|
||||
<p className="profile-footer-copyright">
|
||||
© 2026 Kumusha. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
@@ -1,740 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonInput,
|
||||
IonList,
|
||||
IonModal,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
cameraOutline,
|
||||
chevronBackOutline,
|
||||
closeOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { buildCacheKey, removeCache } from '../utils/localCache';
|
||||
import AvatarPicker from '../components/AvatarPicker';
|
||||
import momImage from '../assets/mom.jpg';
|
||||
import dadImage from '../assets/dad.jpg';
|
||||
import '../styles/recipients.css';
|
||||
|
||||
type Params = { id?: string };
|
||||
|
||||
type RecipientRow = {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
relationship: string;
|
||||
country: string;
|
||||
city: string;
|
||||
mobile_number: string;
|
||||
photo_path: string | null;
|
||||
};
|
||||
|
||||
const countryOptions = [
|
||||
{ name: 'Zimbabwe', flag: '🇿🇼', dialCode: '+263' },
|
||||
{ name: 'South Africa', flag: '🇿🇦', dialCode: '+27' },
|
||||
{ name: 'Zambia', flag: '🇿🇲', dialCode: '+260' },
|
||||
{ name: 'Botswana', flag: '🇧🇼', dialCode: '+267' },
|
||||
{ name: 'Mozambique', flag: '🇲🇿', dialCode: '+258' },
|
||||
{ name: 'United Kingdom', flag: '🇬🇧', dialCode: '+44' },
|
||||
{ name: 'United States', flag: '🇺🇸', dialCode: '+1' },
|
||||
{ name: 'Canada', flag: '🇨🇦', dialCode: '+1' },
|
||||
{ name: 'Australia', flag: '🇦🇺', dialCode: '+61' },
|
||||
];
|
||||
|
||||
const locationOptionsByCountry: Record<
|
||||
string,
|
||||
Array<{
|
||||
group: string;
|
||||
options: Array<{ name: string; description: string }>;
|
||||
}>
|
||||
> = {
|
||||
Zimbabwe: [
|
||||
{
|
||||
group: 'Major cities',
|
||||
options: [
|
||||
{ name: 'Harare', description: 'Capital city coverage' },
|
||||
{ name: 'Bulawayo', description: 'City merchants and pharmacies' },
|
||||
{ name: 'Mutare', description: 'Eastern Highlands coverage' },
|
||||
{ name: 'Gweru', description: 'Midlands city coverage' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Towns',
|
||||
options: [
|
||||
{ name: 'Chitungwiza', description: 'Harare metro support' },
|
||||
{ name: 'Masvingo', description: 'Town and surrounding areas' },
|
||||
{ name: 'Kwekwe', description: 'Supported collection points' },
|
||||
{ name: 'Kadoma', description: 'Supported collection points' },
|
||||
{ name: 'Victoria Falls', description: 'Town coverage' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Rural districts',
|
||||
options: [
|
||||
{ name: 'Murehwa District', description: 'Rural collection support' },
|
||||
{ name: 'Gokwe District', description: 'Rural collection support' },
|
||||
{ name: 'Buhera District', description: 'Rural collection support' },
|
||||
{ name: 'Zaka District', description: 'Rural collection support' },
|
||||
{ name: 'Guruve District', description: 'Rural collection support' },
|
||||
{
|
||||
name: 'Tsholotsho District',
|
||||
description: 'Rural collection support',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
Zambia: [
|
||||
{
|
||||
group: 'Major cities',
|
||||
options: [
|
||||
{ name: 'Lusaka', description: 'Capital city coverage' },
|
||||
{ name: 'Ndola', description: 'Copperbelt coverage' },
|
||||
{ name: 'Kitwe', description: 'Copperbelt coverage' },
|
||||
{ name: 'Livingstone', description: 'Town coverage' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Rural districts',
|
||||
options: [
|
||||
{ name: 'Chongwe District', description: 'Rural collection support' },
|
||||
{ name: 'Monze District', description: 'Rural collection support' },
|
||||
],
|
||||
},
|
||||
],
|
||||
Botswana: [
|
||||
{
|
||||
group: 'Cities and towns',
|
||||
options: [
|
||||
{ name: 'Gaborone', description: 'Capital city coverage' },
|
||||
{ name: 'Francistown', description: 'Town coverage' },
|
||||
{ name: 'Maun', description: 'Town coverage' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Rural districts',
|
||||
options: [
|
||||
{ name: 'Kweneng District', description: 'Rural collection support' },
|
||||
{ name: 'Central District', description: 'Rural collection support' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fallbackLocationGroups = [
|
||||
{
|
||||
group: 'Supported areas',
|
||||
options: [
|
||||
{ name: 'Main city', description: 'Available merchant coverage' },
|
||||
{ name: 'Nearby town', description: 'Supported collection points' },
|
||||
{ name: 'Rural district', description: 'Rural collection support' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const getCountryMeta = (countryName: string) =>
|
||||
countryOptions.find((option) => option.name === countryName) ??
|
||||
countryOptions[0];
|
||||
|
||||
const getLocationGroups = (countryName: string) =>
|
||||
locationOptionsByCountry[countryName] ?? fallbackLocationGroups;
|
||||
|
||||
const getSeededRecipientImage = (
|
||||
firstName?: string | null,
|
||||
photoPath?: string | null
|
||||
) => {
|
||||
const normalizedPath = photoPath?.trim().toLowerCase();
|
||||
if (normalizedPath === 'mom.jpg' || normalizedPath === 'mum.jpg') {
|
||||
return momImage;
|
||||
}
|
||||
if (normalizedPath === 'dad.jpg' || normalizedPath === 'father.jpg') {
|
||||
return dadImage;
|
||||
}
|
||||
|
||||
const normalizedName = firstName?.trim().toLowerCase();
|
||||
if (normalizedName === 'mum' || normalizedName === 'mom') {
|
||||
return momImage;
|
||||
}
|
||||
if (normalizedName === 'dad' || normalizedName === 'father') {
|
||||
return dadImage;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const stripDialCode = (value: string, dialCode: string) => {
|
||||
const normalizedValue = value.trim();
|
||||
if (normalizedValue.startsWith(dialCode)) {
|
||||
return normalizedValue.slice(dialCode.length).trimStart();
|
||||
}
|
||||
return normalizedValue;
|
||||
};
|
||||
|
||||
const sanitizePhoneInput = (value: string) =>
|
||||
value.replace(/[^\d\s()-]/g, '').replace(/\s{2,}/g, ' ');
|
||||
|
||||
const normalizePhoneInput = (value: string) =>
|
||||
sanitizePhoneInput(value).replace(/^0+/, '');
|
||||
|
||||
const formatNameInput = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/(^|[\s'-])([a-z])/g,
|
||||
(_match, separator: string, letter: string) =>
|
||||
`${separator}${letter.toUpperCase()}`
|
||||
);
|
||||
|
||||
const RecipientFormPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { id } = useParams<Params>();
|
||||
const { user } = useAuth();
|
||||
const isEdit = Boolean(id);
|
||||
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [relationship, setRelationship] = useState('');
|
||||
const [country, setCountry] = useState('Zimbabwe');
|
||||
const [city, setCity] = useState('');
|
||||
const [mobileNumber, setMobileNumber] = useState('');
|
||||
const [showCountrySheet, setShowCountrySheet] = useState(false);
|
||||
const [showLocationSheet, setShowLocationSheet] = useState(false);
|
||||
const selectedCountry = getCountryMeta(country);
|
||||
const locationGroups = getLocationGroups(country);
|
||||
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
||||
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
||||
const previewObjectUrlRef = useRef<string | null>(null);
|
||||
const [existingPhotoPath, setExistingPhotoPath] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const firstNameInputRef = useRef<HTMLIonInputElement>(null);
|
||||
const lastNameInputRef = useRef<HTMLIonInputElement>(null);
|
||||
const relationshipInputRef = useRef<HTMLIonInputElement>(null);
|
||||
const mobileInputRef = useRef<HTMLIonInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
void loadRecipient();
|
||||
}
|
||||
}, [id, isEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewObjectUrlRef.current) {
|
||||
URL.revokeObjectURL(previewObjectUrlRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (history.length > 1) {
|
||||
history.goBack();
|
||||
} else {
|
||||
history.replace('/recipients');
|
||||
}
|
||||
};
|
||||
|
||||
const showError = (message: string) => {
|
||||
setError(message);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
const applyRecipientToForm = async (recipient: RecipientRow) => {
|
||||
setFirstName(recipient.first_name);
|
||||
setLastName(recipient.last_name);
|
||||
setRelationship(recipient.relationship);
|
||||
setCountry(recipient.country);
|
||||
setCity(recipient.city);
|
||||
const recipientCountry = getCountryMeta(recipient.country);
|
||||
setMobileNumber(
|
||||
stripDialCode(recipient.mobile_number, recipientCountry.dialCode)
|
||||
);
|
||||
setExistingPhotoPath(recipient.photo_path);
|
||||
|
||||
const seededImage = getSeededRecipientImage(
|
||||
recipient.first_name,
|
||||
recipient.photo_path
|
||||
);
|
||||
if (seededImage) {
|
||||
setPhotoPreview(seededImage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recipient.photo_path) {
|
||||
setPhotoPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: signed } = await supabase.storage
|
||||
.from('recipient-photos')
|
||||
.createSignedUrl(recipient.photo_path, 3600);
|
||||
setPhotoPreview(
|
||||
signed?.signedUrl
|
||||
? `${signed.signedUrl}&v=${encodeURIComponent(recipient.photo_path)}`
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const loadRecipient = async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
|
||||
const ownerId = user?.id ?? '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
const { data, error: loadError } = await supabase
|
||||
.from('recipients')
|
||||
.select(
|
||||
'id,first_name,last_name,relationship,country,city,mobile_number,photo_path'
|
||||
)
|
||||
.eq('id', id)
|
||||
.eq('user_id', ownerId)
|
||||
.single();
|
||||
|
||||
if (loadError || !data) {
|
||||
showError(loadError?.message ?? 'Recipient not found');
|
||||
setLoading(false);
|
||||
history.replace('/recipients');
|
||||
return;
|
||||
}
|
||||
|
||||
const recipient = data as RecipientRow;
|
||||
await applyRecipientToForm(recipient);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handlePhotoChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (previewObjectUrlRef.current) {
|
||||
URL.revokeObjectURL(previewObjectUrlRef.current);
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
previewObjectUrlRef.current = objectUrl;
|
||||
setPhotoFile(file);
|
||||
setPhotoPreview(objectUrl);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!user) {
|
||||
showError('Please sign in again to save this loved one');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
first_name: firstName.trim(),
|
||||
last_name: lastName.trim(),
|
||||
relationship: relationship.trim(),
|
||||
country: country.trim(),
|
||||
city: city.trim(),
|
||||
mobile_number:
|
||||
`${selectedCountry.dialCode} ${mobileNumber.trim()}`.trim(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (
|
||||
!payload.first_name ||
|
||||
!payload.last_name ||
|
||||
!payload.relationship ||
|
||||
!payload.country ||
|
||||
!payload.city ||
|
||||
!payload.mobile_number
|
||||
) {
|
||||
showError('Please complete all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
let photoPath = existingPhotoPath;
|
||||
if (photoFile) {
|
||||
const extension = photoFile.name.split('.').pop() || 'jpg';
|
||||
photoPath = `${user.id}/${Date.now()}.${extension}`;
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('recipient-photos')
|
||||
.upload(photoPath, photoFile);
|
||||
|
||||
if (uploadError) {
|
||||
showError(uploadError.message);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const recipientsCacheKey = buildCacheKey(user.id, 'recipientsList');
|
||||
|
||||
if (isEdit && id) {
|
||||
const { error: updateError } = await supabase
|
||||
.from('recipients')
|
||||
.update({ ...payload, photo_path: photoPath })
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (updateError) {
|
||||
showError(updateError.message);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
removeCache(recipientsCacheKey),
|
||||
removeCache(buildCacheKey(user.id, `recipient_${id}`)),
|
||||
]);
|
||||
setSaving(false);
|
||||
history.push(`/recipients/${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error: insertError } = await supabase
|
||||
.from('recipients')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
...payload,
|
||||
photo_path: photoPath,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (insertError || !data) {
|
||||
showError(insertError?.message ?? 'Could not create recipient');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await removeCache(recipientsCacheKey);
|
||||
setSaving(false);
|
||||
history.replace(`/recipients/${data.id}`);
|
||||
};
|
||||
|
||||
const initials = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||
|
||||
return (
|
||||
<IonPage style={{ backgroundColor: '#fafafa' }}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar
|
||||
style={
|
||||
{
|
||||
'--background': '#fafafa',
|
||||
'--border-width': '0px',
|
||||
'--color': '#111827',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<IonButtons slot="start">
|
||||
<IonButton
|
||||
className="recipients-back-button"
|
||||
fill="clear"
|
||||
onClick={handleGoBack}
|
||||
aria-label="Go back"
|
||||
>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle style={{ fontSize: '18px', fontWeight: 700 }}>
|
||||
{isEdit ? 'Edit loved one' : 'Add loved one'}
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent
|
||||
style={
|
||||
{
|
||||
'--background': '#fafafa',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': '8px',
|
||||
'--padding-bottom': 'calc(32px + var(--ion-safe-area-bottom, 0px))',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="recipient-form-card">
|
||||
<div className="rf-photo-picker">
|
||||
<IonIcon icon={cameraOutline} style={{ display: 'none' }} />
|
||||
<AvatarPicker
|
||||
previewUrl={photoPreview}
|
||||
onFileChange={handlePhotoChange}
|
||||
initials={initials || undefined}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="rf-input-shell rf-text-shell"
|
||||
onClick={() => void firstNameInputRef.current?.setFocus()}
|
||||
disabled={loading || saving}
|
||||
aria-label="Enter first name"
|
||||
>
|
||||
<span className="rf-field-label">First name</span>
|
||||
<IonInput
|
||||
ref={firstNameInputRef}
|
||||
className="rf-field"
|
||||
type="text"
|
||||
placeholder="Sarah"
|
||||
value={firstName}
|
||||
disabled={loading || saving}
|
||||
onIonInput={(event) =>
|
||||
setFirstName(formatNameInput(event.detail.value ?? ''))
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rf-input-shell rf-text-shell"
|
||||
onClick={() => void lastNameInputRef.current?.setFocus()}
|
||||
disabled={loading || saving}
|
||||
aria-label="Enter last name"
|
||||
>
|
||||
<span className="rf-field-label">Last name</span>
|
||||
<IonInput
|
||||
ref={lastNameInputRef}
|
||||
className="rf-field"
|
||||
type="text"
|
||||
placeholder="Moyo"
|
||||
value={lastName}
|
||||
disabled={loading || saving}
|
||||
onIonInput={(event) =>
|
||||
setLastName(formatNameInput(event.detail.value ?? ''))
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rf-input-shell rf-text-shell"
|
||||
onClick={() => void relationshipInputRef.current?.setFocus()}
|
||||
disabled={loading || saving}
|
||||
aria-label="Enter relationship"
|
||||
>
|
||||
<span className="rf-field-label">Relationship</span>
|
||||
<IonInput
|
||||
ref={relationshipInputRef}
|
||||
className="rf-field"
|
||||
type="text"
|
||||
placeholder="Mum, Brother, Aunt"
|
||||
value={relationship}
|
||||
disabled={loading || saving}
|
||||
onIonInput={(event) =>
|
||||
setRelationship(formatNameInput(event.detail.value ?? ''))
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rf-country-trigger"
|
||||
onClick={() => setShowCountrySheet(true)}
|
||||
disabled={loading || saving}
|
||||
aria-label="Select country"
|
||||
>
|
||||
<span className="rf-country-label">Country</span>
|
||||
<span className="rf-country-value-row">
|
||||
<span className="rf-country-value">
|
||||
{`${selectedCountry.flag} ${selectedCountry.name} (${selectedCountry.dialCode})`}
|
||||
</span>
|
||||
<IonIcon
|
||||
icon={chevronBackOutline}
|
||||
className="rf-country-chevron"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rf-country-trigger"
|
||||
onClick={() => setShowLocationSheet(true)}
|
||||
disabled={loading || saving}
|
||||
aria-label="Select recipient location"
|
||||
>
|
||||
<span className="rf-country-label">Location</span>
|
||||
<span className="rf-country-value-row">
|
||||
<span
|
||||
className={`rf-country-value${city ? '' : ' is-placeholder'}`}
|
||||
>
|
||||
{city || 'City, town, or rural area'}
|
||||
</span>
|
||||
<IonIcon
|
||||
icon={chevronBackOutline}
|
||||
className="rf-country-chevron"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rf-input-shell rf-phone-shell"
|
||||
onClick={() => void mobileInputRef.current?.setFocus()}
|
||||
disabled={loading || saving}
|
||||
aria-label="Enter mobile number"
|
||||
>
|
||||
<span className="rf-field-label">Mobile number</span>
|
||||
<div className="rf-phone-row">
|
||||
<div className="rf-phone-prefix" aria-hidden="true">
|
||||
<span className="rf-phone-flag">{selectedCountry.flag}</span>
|
||||
<span className="rf-phone-code">
|
||||
{selectedCountry.dialCode}
|
||||
</span>
|
||||
</div>
|
||||
<IonInput
|
||||
ref={mobileInputRef}
|
||||
className="rf-field rf-phone-field"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
placeholder="77 123 4567"
|
||||
value={mobileNumber}
|
||||
disabled={loading || saving}
|
||||
onIonInput={(event) =>
|
||||
setMobileNumber(
|
||||
normalizePhoneInput(event.detail.value ?? '')
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
{error && (
|
||||
<p style={{ margin: 0, color: '#dc2626', fontSize: '13px' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rf-actions">
|
||||
<IonButton
|
||||
type="submit"
|
||||
expand="block"
|
||||
className="rf-submit-btn"
|
||||
disabled={loading || saving}
|
||||
>
|
||||
{saving ? 'Saving...' : isEdit ? 'Save changes' : 'Add loved one'}
|
||||
</IonButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<IonModal
|
||||
isOpen={showCountrySheet}
|
||||
onDidDismiss={() => setShowCountrySheet(false)}
|
||||
initialBreakpoint={1}
|
||||
breakpoints={[0, 1]}
|
||||
handle={true}
|
||||
className="country-sheet-modal"
|
||||
>
|
||||
<div className="country-sheet-shell">
|
||||
<div className="country-sheet-header">
|
||||
<h2>Select country</h2>
|
||||
<IonButton
|
||||
fill="clear"
|
||||
className="country-sheet-close"
|
||||
onClick={() => setShowCountrySheet(false)}
|
||||
aria-label="Close country selector"
|
||||
>
|
||||
<IonIcon icon={closeOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</div>
|
||||
<div className="country-sheet-scroll">
|
||||
<IonList lines="none" className="country-sheet-list">
|
||||
{countryOptions.map((option) => {
|
||||
const isSelected = option.name === country;
|
||||
return (
|
||||
<button
|
||||
key={option.name}
|
||||
type="button"
|
||||
className={`country-sheet-option${isSelected ? ' is-selected' : ''}`}
|
||||
onClick={() => {
|
||||
setCountry(option.name);
|
||||
setCity('');
|
||||
setShowCountrySheet(false);
|
||||
}}
|
||||
>
|
||||
<span className="country-sheet-option-text">
|
||||
<span className="country-sheet-flag">
|
||||
{option.flag}
|
||||
</span>
|
||||
<span className="country-sheet-name">
|
||||
{option.name}
|
||||
</span>
|
||||
<span className="country-sheet-code">
|
||||
({option.dialCode})
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</IonList>
|
||||
</div>
|
||||
</div>
|
||||
</IonModal>
|
||||
|
||||
<IonModal
|
||||
isOpen={showLocationSheet}
|
||||
onDidDismiss={() => setShowLocationSheet(false)}
|
||||
initialBreakpoint={1}
|
||||
breakpoints={[0, 1]}
|
||||
handle={true}
|
||||
className="country-sheet-modal"
|
||||
>
|
||||
<div className="country-sheet-shell">
|
||||
<div className="country-sheet-header">
|
||||
<h2>Select location</h2>
|
||||
<IonButton
|
||||
fill="clear"
|
||||
className="country-sheet-close"
|
||||
onClick={() => setShowLocationSheet(false)}
|
||||
aria-label="Close location selector"
|
||||
>
|
||||
<IonIcon icon={closeOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</div>
|
||||
<div className="country-sheet-scroll">
|
||||
<div className="location-sheet-helper">
|
||||
Choose a city, town, or rural district where support collection
|
||||
is available.
|
||||
</div>
|
||||
{locationGroups.map((group) => (
|
||||
<div key={group.group} className="location-sheet-group">
|
||||
<p className="location-sheet-group-title">{group.group}</p>
|
||||
{group.options.map((option) => {
|
||||
const isSelected = option.name === city;
|
||||
return (
|
||||
<button
|
||||
key={option.name}
|
||||
type="button"
|
||||
className={`country-sheet-option location-sheet-option${isSelected ? ' is-selected' : ''}`}
|
||||
onClick={() => {
|
||||
setCity(option.name);
|
||||
setShowLocationSheet(false);
|
||||
}}
|
||||
>
|
||||
<span className="location-sheet-option-text">
|
||||
<span className="location-sheet-name">
|
||||
{option.name}
|
||||
</span>
|
||||
<span className="location-sheet-description">
|
||||
{option.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</IonModal>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipientFormPage;
|
||||
@@ -1,347 +0,0 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonContent,
|
||||
IonIcon,
|
||||
IonPage,
|
||||
IonRefresher,
|
||||
IonRefresherContent,
|
||||
IonSkeletonText,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { addOutline, peopleOutline } from 'ionicons/icons';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import dadImage from '../assets/dad.jpg';
|
||||
import momImage from '../assets/mom.jpg';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import {
|
||||
buildCacheKey,
|
||||
readCache,
|
||||
removeCache,
|
||||
writeCache,
|
||||
} from '../utils/localCache';
|
||||
import ListSearchRow from '../components/ListSearchRow';
|
||||
import RecipientListCard, {
|
||||
RecipientListItem,
|
||||
} from '../components/RecipientListCard';
|
||||
import '../styles/recipients.css';
|
||||
|
||||
type RecipientRow = {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
relationship: string;
|
||||
country: string;
|
||||
city: string;
|
||||
photo_path: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type ActivityRow = {
|
||||
recipient_id: string | null;
|
||||
event_at: string;
|
||||
};
|
||||
|
||||
const formatLastActivity = (dateText?: string) => {
|
||||
if (!dateText) return 'No support yet';
|
||||
const diffMs = Date.now() - new Date(dateText).getTime();
|
||||
const diffDays = Math.max(0, Math.floor(diffMs / 86400000));
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
return `${diffDays} days ago`;
|
||||
};
|
||||
|
||||
const getSeededRecipientImage = (firstName?: string | null) => {
|
||||
const normalized = firstName?.trim().toLowerCase();
|
||||
if (normalized === 'mum' || normalized === 'mom') return momImage;
|
||||
if (normalized === 'dad' || normalized === 'father') return dadImage;
|
||||
return null;
|
||||
};
|
||||
|
||||
const recipientsListMemory = new Map<string, RecipientListItem[]>();
|
||||
|
||||
const getRecipientAvatarUrl = async (photoPath: string | null) => {
|
||||
if (!photoPath) return null;
|
||||
|
||||
const { data: signedData } = await supabase.storage
|
||||
.from('recipient-photos')
|
||||
.createSignedUrl(photoPath, 3600);
|
||||
|
||||
if (signedData?.signedUrl) {
|
||||
return `${signedData.signedUrl}&v=${encodeURIComponent(photoPath)}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const RecipientsPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user } = useAuth();
|
||||
const activeInitialUserId =
|
||||
user?.id ?? '00000000-0000-0000-0000-000000000000';
|
||||
const initialRecipients = recipientsListMemory.get(activeInitialUserId) ?? [];
|
||||
const [recipients, setRecipients] =
|
||||
useState<RecipientListItem[]>(initialRecipients);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [loading, setLoading] = useState(initialRecipients.length === 0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
void loadRecipients({ forceRefresh: true });
|
||||
});
|
||||
|
||||
const showError = (message: string) => {
|
||||
setError(message);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
const loadRecipients = async (options?: { forceRefresh?: boolean }) => {
|
||||
const activeUserId = user?.id ?? '00000000-0000-0000-0000-000000000000';
|
||||
const cacheKey = buildCacheKey(activeUserId, 'recipientsList');
|
||||
|
||||
if (options?.forceRefresh) {
|
||||
recipientsListMemory.delete(activeUserId);
|
||||
await removeCache(cacheKey);
|
||||
}
|
||||
|
||||
let hasCache = false;
|
||||
if (!options?.forceRefresh) {
|
||||
const memoryCached = recipientsListMemory.get(activeUserId);
|
||||
if (memoryCached) {
|
||||
setRecipients(memoryCached);
|
||||
setLoading(false);
|
||||
hasCache = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const cached = await readCache<RecipientListItem[]>(cacheKey);
|
||||
if (cached && Array.isArray(cached)) {
|
||||
recipientsListMemory.set(activeUserId, cached);
|
||||
setRecipients(cached);
|
||||
setLoading(false);
|
||||
hasCache = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[recipients cache] error', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCache) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const [recipientsResult, activityResult] = await Promise.all([
|
||||
supabase
|
||||
.from('recipients')
|
||||
.select(
|
||||
'id,first_name,last_name,relationship,country,city,photo_path,created_at,is_active,archived_at,pinned_at'
|
||||
)
|
||||
.eq('user_id', activeUserId)
|
||||
.is('archived_at', null)
|
||||
.order('pinned_at', { ascending: false, nullsFirst: false })
|
||||
.order('created_at', { ascending: false }),
|
||||
supabase
|
||||
.from('activity_events')
|
||||
.select('recipient_id,event_at')
|
||||
.eq('user_id', activeUserId)
|
||||
.order('event_at', { ascending: false })
|
||||
.limit(100),
|
||||
]);
|
||||
|
||||
const queryError = recipientsResult.error ?? activityResult.error;
|
||||
if (queryError) {
|
||||
showError(queryError.message);
|
||||
if (!hasCache) setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const activities = (activityResult.data ?? []) as ActivityRow[];
|
||||
// Filter active on the client if it's the real user; preview user bypass may not have is_active set consistently
|
||||
const rows = (recipientsResult.data ?? []) as (RecipientRow & {
|
||||
is_active: boolean | null;
|
||||
})[];
|
||||
const activeRows = user ? rows.filter((r) => r.is_active !== false) : rows;
|
||||
|
||||
const list = await Promise.all(
|
||||
activeRows.map(async (recipient) => {
|
||||
const latestActivity = activities.find(
|
||||
(a) => a.recipient_id === recipient.id
|
||||
);
|
||||
|
||||
let avatarUrl = await getRecipientAvatarUrl(recipient.photo_path);
|
||||
|
||||
if (!avatarUrl) {
|
||||
avatarUrl = getSeededRecipientImage(recipient.first_name);
|
||||
}
|
||||
|
||||
return {
|
||||
id: recipient.id,
|
||||
firstName: recipient.first_name,
|
||||
lastName: recipient.last_name,
|
||||
relationship: recipient.relationship,
|
||||
location: `${recipient.city}, ${recipient.country}`,
|
||||
lastActivityText: formatLastActivity(latestActivity?.event_at),
|
||||
avatarUrl,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const nextHash = JSON.stringify(list);
|
||||
const currentHash = JSON.stringify(
|
||||
recipientsListMemory.get(activeUserId) ?? recipients
|
||||
);
|
||||
recipientsListMemory.set(activeUserId, list);
|
||||
if (nextHash !== currentHash) {
|
||||
setRecipients(list);
|
||||
}
|
||||
setLoading(false);
|
||||
void writeCache(cacheKey, list);
|
||||
};
|
||||
|
||||
const visibleRecipients = useMemo(() => {
|
||||
const query = searchTerm.trim().toLowerCase();
|
||||
if (!query) return recipients;
|
||||
return recipients.filter((recipient) =>
|
||||
[
|
||||
recipient.firstName,
|
||||
recipient.lastName,
|
||||
recipient.relationship,
|
||||
recipient.location,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
);
|
||||
}, [recipients, searchTerm]);
|
||||
|
||||
const handleRefresh = async (event: CustomEvent) => {
|
||||
await loadRecipients({ forceRefresh: true });
|
||||
event.detail.complete();
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage style={{ backgroundColor: '#fafafa' }}>
|
||||
<IonContent
|
||||
fullscreen
|
||||
className="recipients-shell"
|
||||
style={
|
||||
{
|
||||
'--background': 'linear-gradient(180deg, #fafafa 0%, #f4f0ff 100%)',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': 'calc(var(--ion-safe-area-top, 0px) + 8px)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
|
||||
<IonRefresherContent />
|
||||
</IonRefresher>
|
||||
|
||||
<div className="recipients-top-row" style={{ padding: '16px 0 12px' }}>
|
||||
<div>
|
||||
<h1 className="recipients-page-title">Loved ones</h1>
|
||||
<p className="rlc-location" style={{ marginTop: '4px' }}>
|
||||
Manage family and care recipients
|
||||
</p>
|
||||
</div>
|
||||
<IonButton
|
||||
className="recipients-add-btn"
|
||||
onClick={() => history.push('/recipients/new')}
|
||||
>
|
||||
<IonIcon icon={addOutline} slot="start" />
|
||||
Add
|
||||
</IonButton>
|
||||
</div>
|
||||
|
||||
<ListSearchRow
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Search loved ones..."
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
style={{
|
||||
margin: '0 20px 12px',
|
||||
color: '#dc2626',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div>
|
||||
{[1, 2, 3].map((item) => (
|
||||
<div key={item} className="recipient-list-card">
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '52px',
|
||||
height: '52px',
|
||||
borderRadius: '16px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '60%', height: '16px' }}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '40%', height: '12px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : visibleRecipients.length === 0 ? (
|
||||
<div className="empty-state-card">
|
||||
<IonIcon icon={peopleOutline} className="esc-icon" />
|
||||
<h2 className="esc-title">
|
||||
{recipients.length === 0
|
||||
? 'No loved ones yet'
|
||||
: 'No matches found'}
|
||||
</h2>
|
||||
<p className="esc-msg">
|
||||
{recipients.length === 0
|
||||
? 'Add your first loved one so you can send support with confidence.'
|
||||
: 'Try a different name, relationship, or city.'}
|
||||
</p>
|
||||
{recipients.length === 0 && (
|
||||
<IonButton
|
||||
onClick={() => history.push('/recipients/new')}
|
||||
style={
|
||||
{
|
||||
'--background': '#6d28d9',
|
||||
'--border-radius': '999px',
|
||||
'--box-shadow': 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<IonIcon icon={addOutline} slot="start" />
|
||||
Add loved one
|
||||
</IonButton>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{visibleRecipients.map((recipient) => (
|
||||
<RecipientListCard
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
onClick={(id) => history.push(`/recipients/${id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipientsPage;
|
||||
@@ -1,367 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { IonButton, IonContent, IonInput, IonPage } from '@ionic/react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import AvatarPicker from '../components/AvatarPicker';
|
||||
import '../styles/auth.css';
|
||||
|
||||
type ProfileDraft = {
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
phone?: string | null;
|
||||
country_of_residence?: string | null;
|
||||
avatar_path?: string | null;
|
||||
};
|
||||
|
||||
const SetupProfilePage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { user, refreshProfile, signOut } = useAuth();
|
||||
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [country, setCountry] = useState('');
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const messageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [prefilling, setPrefilling] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (messageTimerRef.current) {
|
||||
clearTimeout(messageTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadExistingProfile();
|
||||
}, [user?.id]);
|
||||
|
||||
const showError = (message: string) => {
|
||||
if (messageTimerRef.current) {
|
||||
clearTimeout(messageTimerRef.current);
|
||||
}
|
||||
setError(message);
|
||||
messageTimerRef.current = setTimeout(() => {
|
||||
if (mountedRef.current) {
|
||||
setError(null);
|
||||
}
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
const loadExistingProfile = async () => {
|
||||
if (!user) {
|
||||
setPrefilling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPrefilling(true);
|
||||
let profile: ProfileDraft | null = null;
|
||||
let profileLoadError: unknown = null;
|
||||
|
||||
try {
|
||||
const profileLoadRequest = supabase
|
||||
.from('profiles')
|
||||
.select('first_name,last_name,phone,country_of_residence,avatar_path')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
const result = await Promise.race([
|
||||
profileLoadRequest,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Profile setup loading timed out.')),
|
||||
8000
|
||||
)
|
||||
),
|
||||
]);
|
||||
|
||||
profile = result.data as ProfileDraft | null;
|
||||
profileLoadError = result.error;
|
||||
} catch (loadError) {
|
||||
profileLoadError = loadError;
|
||||
}
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
if (profileLoadError) {
|
||||
console.error(
|
||||
'[SetupProfilePage] Failed to load existing profile',
|
||||
profileLoadError
|
||||
);
|
||||
showError(
|
||||
'We could not load your profile yet. You can still finish setup.'
|
||||
);
|
||||
}
|
||||
if (profile) {
|
||||
setFirstName(profile.first_name ?? '');
|
||||
setLastName(profile.last_name ?? '');
|
||||
setPhone(profile.phone ?? '');
|
||||
setCountry(profile.country_of_residence ?? '');
|
||||
if (profile.avatar_path) {
|
||||
const { data: publicUrlData } = supabase.storage
|
||||
.from('avatars')
|
||||
.getPublicUrl(profile.avatar_path);
|
||||
setAvatarPreview(publicUrlData.publicUrl);
|
||||
}
|
||||
} else if (user.user_metadata?.full_name) {
|
||||
const parts = String(user.user_metadata.full_name).split(' ');
|
||||
setFirstName(parts[0] ?? '');
|
||||
setLastName(parts.slice(1).join(' '));
|
||||
}
|
||||
setPrefilling(false);
|
||||
};
|
||||
|
||||
const handleAvatarChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (!mountedRef.current) return;
|
||||
setAvatarFile(file);
|
||||
setAvatarPreview(
|
||||
typeof reader.result === 'string' ? reader.result : null
|
||||
);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
showError(
|
||||
'We could not preview that image. Please choose another photo.'
|
||||
);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!user) {
|
||||
showError('Please sign in again to complete your profile');
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanFirstName = firstName.trim();
|
||||
const cleanLastName = lastName.trim();
|
||||
const cleanPhone = phone.trim();
|
||||
const cleanCountry = country.trim();
|
||||
|
||||
if (!cleanFirstName || !cleanLastName || !cleanPhone || !cleanCountry) {
|
||||
showError('Please complete all profile fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let avatarPath: string | null = null;
|
||||
if (avatarFile) {
|
||||
const extension = avatarFile.name.split('.').pop() || 'jpg';
|
||||
avatarPath = `${user.id}/${Date.now()}.${extension}`;
|
||||
const uploadRequest = supabase.storage
|
||||
.from('avatars')
|
||||
.upload(avatarPath, avatarFile);
|
||||
const { error: uploadError } = await Promise.race([
|
||||
uploadRequest,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(new Error('Photo upload timed out. Please try again.')),
|
||||
12000
|
||||
)
|
||||
),
|
||||
]);
|
||||
|
||||
if (uploadError) {
|
||||
showError(uploadError.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const profilePayload = {
|
||||
id: user.id,
|
||||
first_name: cleanFirstName,
|
||||
last_name: cleanLastName,
|
||||
full_name: `${cleanFirstName} ${cleanLastName}`,
|
||||
phone: cleanPhone,
|
||||
country_of_residence: cleanCountry,
|
||||
...(avatarPath ? { avatar_path: avatarPath } : {}),
|
||||
notification_push_enabled: true,
|
||||
notification_email_enabled: true,
|
||||
notification_sms_enabled: false,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
const profileSaveRequest = supabase
|
||||
.from('profiles')
|
||||
.upsert(profilePayload, { onConflict: 'id' })
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
const { data: savedProfile, error: profileError } = await Promise.race([
|
||||
profileSaveRequest,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(new Error('Profile save timed out. Please try again.')),
|
||||
12000
|
||||
)
|
||||
),
|
||||
]);
|
||||
|
||||
if (profileError) {
|
||||
showError(profileError.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshProfile();
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
setLoading(false);
|
||||
history.replace('/home');
|
||||
} catch (submitError: any) {
|
||||
if (!mountedRef.current) return;
|
||||
showError(
|
||||
submitError.message || 'Profile setup failed. Please try again.'
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initials = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (loading || prefilling) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await signOut();
|
||||
history.replace('/auth');
|
||||
} catch (logoutError: any) {
|
||||
showError(
|
||||
logoutError?.message || 'We could not sign you out. Please try again.'
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage style={{ backgroundColor: '#fafafa' }}>
|
||||
<IonContent
|
||||
className="auth-content"
|
||||
style={
|
||||
{
|
||||
'--background': 'linear-gradient(180deg, #fafafa 0%, #f4f0ff 100%)',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': '0px',
|
||||
'--padding-bottom': '0px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="auth-shell">
|
||||
<div className="auth-brand-block">
|
||||
<h4 className="auth-eyebrow">Profile setup</h4>
|
||||
<h1 className="auth-heading">Tell us about you</h1>
|
||||
<p className="auth-subtitle">
|
||||
We use this to personalise your Kumusha dashboard and keep your
|
||||
account secure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form-card">
|
||||
<AvatarPicker
|
||||
previewUrl={avatarPreview}
|
||||
onFileChange={handleAvatarChange}
|
||||
initials={initials || undefined}
|
||||
disabled={loading || prefilling}
|
||||
/>
|
||||
|
||||
<IonInput
|
||||
type="text"
|
||||
label="First name"
|
||||
labelPlacement="floating"
|
||||
value={firstName}
|
||||
onIonInput={(event) => setFirstName(event.detail.value ?? '')}
|
||||
disabled={loading || prefilling}
|
||||
placeholder="e.g. Tadiwa"
|
||||
className="auth-field"
|
||||
/>
|
||||
<IonInput
|
||||
type="text"
|
||||
label="Last name"
|
||||
labelPlacement="floating"
|
||||
value={lastName}
|
||||
onIonInput={(event) => setLastName(event.detail.value ?? '')}
|
||||
disabled={loading || prefilling}
|
||||
placeholder="e.g. Moyo"
|
||||
className="auth-field"
|
||||
/>
|
||||
<IonInput
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
label="Mobile number"
|
||||
labelPlacement="floating"
|
||||
value={phone}
|
||||
onIonInput={(event) => setPhone(event.detail.value ?? '')}
|
||||
disabled={loading || prefilling}
|
||||
placeholder="e.g. +44 7123 456789"
|
||||
className="auth-field"
|
||||
/>
|
||||
<IonInput
|
||||
type="text"
|
||||
label="Country of residence"
|
||||
labelPlacement="floating"
|
||||
value={country}
|
||||
onIonInput={(event) => setCountry(event.detail.value ?? '')}
|
||||
disabled={loading || prefilling}
|
||||
placeholder="e.g. United Kingdom"
|
||||
className="auth-field"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="auth-status-text" style={{ color: '#dc2626' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="setup-profile-actions-row">
|
||||
<IonButton
|
||||
type="button"
|
||||
fill="outline"
|
||||
disabled={loading || prefilling}
|
||||
onClick={handleLogout}
|
||||
className="setup-profile-secondary-btn"
|
||||
>
|
||||
Logout
|
||||
</IonButton>
|
||||
<IonButton
|
||||
type="submit"
|
||||
expand="block"
|
||||
disabled={loading || prefilling}
|
||||
className="setup-profile-primary-btn"
|
||||
>
|
||||
{loading ? 'Saving profile...' : 'Finish setup'}
|
||||
</IonButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupProfilePage;
|
||||
@@ -1,78 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonContent, IonIcon, IonPage } from '@ionic/react';
|
||||
import {
|
||||
flashOutline,
|
||||
heart,
|
||||
medkitOutline,
|
||||
phonePortraitOutline,
|
||||
} from 'ionicons/icons';
|
||||
import basketImage from '../assets/basket.png';
|
||||
import '../styles/auth.css';
|
||||
|
||||
interface SplashPageProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
progressLabel?: string;
|
||||
}
|
||||
|
||||
const SplashPage: React.FC<SplashPageProps> = ({
|
||||
title = 'Send support to loved ones from anywhere',
|
||||
subtitle,
|
||||
progressLabel,
|
||||
}) => {
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent
|
||||
fullscreen
|
||||
style={
|
||||
{
|
||||
'--background':
|
||||
'linear-gradient(180deg, #fafafa 0%, #f6f1ff 52%, #f4f0ff 100%)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="app-splash-shell">
|
||||
<div className="app-splash-card">
|
||||
<div className="app-splash-logo-wrap" aria-hidden="true">
|
||||
<div className="app-splash-category-orbit app-splash-category-orbit--groceries">
|
||||
<img
|
||||
src={basketImage}
|
||||
alt=""
|
||||
className="app-splash-category-image"
|
||||
/>
|
||||
</div>
|
||||
<div className="app-splash-category-orbit app-splash-category-orbit--medicine">
|
||||
<IonIcon icon={medkitOutline} />
|
||||
</div>
|
||||
<div className="app-splash-category-orbit app-splash-category-orbit--airtime">
|
||||
<IonIcon icon={phonePortraitOutline} />
|
||||
</div>
|
||||
<div className="app-splash-category-orbit app-splash-category-orbit--electricity">
|
||||
<IonIcon icon={flashOutline} />
|
||||
</div>
|
||||
<div className="app-splash-logo-glow" />
|
||||
<div className="app-splash-logo-circle">
|
||||
<IonIcon icon={heart} />
|
||||
</div>
|
||||
<div className="app-splash-heart-ripple app-splash-heart-ripple--one" />
|
||||
<div className="app-splash-heart-ripple app-splash-heart-ripple--two" />
|
||||
</div>
|
||||
<p className="app-splash-brand">Kumusha</p>
|
||||
<h1 className="app-splash-title">{title}</h1>
|
||||
{subtitle ? (
|
||||
<p className="app-splash-subtitle">{subtitle}</p>
|
||||
) : null}
|
||||
<div className="app-splash-progress-track">
|
||||
<span className="app-splash-progress-bar" />
|
||||
</div>
|
||||
{progressLabel ? (
|
||||
<p className="app-splash-progress-label">{progressLabel}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default SplashPage;
|
||||
@@ -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,297 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonButtons,
|
||||
IonTitle,
|
||||
IonButton,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
import { chevronBackOutline, mailOutline } from 'ionicons/icons';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import OtpInputSlots from '../components/OtpInputSlots';
|
||||
import '../styles/auth.css';
|
||||
|
||||
interface LocationState {
|
||||
email?: string;
|
||||
resent?: boolean;
|
||||
}
|
||||
|
||||
const VerifyEmailPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const location = useLocation<LocationState>();
|
||||
const { user, profile, profileStatus } = useAuth();
|
||||
|
||||
const email =
|
||||
location.state?.email?.trim().toLowerCase() ||
|
||||
localStorage.getItem('kumusha_pending_verification_email') ||
|
||||
'';
|
||||
const wasResent = location.state?.resent;
|
||||
|
||||
const [code, setCode] = useState<string[]>(['', '', '', '', '', '']);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(
|
||||
wasResent ? 'We sent a fresh code to your email.' : null
|
||||
);
|
||||
const [cooldown, setCooldown] = useState(wasResent ? 60 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!email) {
|
||||
showError(
|
||||
'We could not find the email used for sign up. Please sign up again.'
|
||||
);
|
||||
}
|
||||
}, [email]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || profileStatus === 'loading') return;
|
||||
|
||||
if (
|
||||
!profile ||
|
||||
!profile.first_name ||
|
||||
!profile.last_name ||
|
||||
!profile.phone ||
|
||||
!profile.country_of_residence
|
||||
) {
|
||||
history.replace('/setup-profile');
|
||||
} else {
|
||||
history.replace('/home');
|
||||
}
|
||||
}, [user, profile, profileStatus, history]);
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setError(null);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (cooldown > 0) {
|
||||
timer = setTimeout(() => setCooldown((c) => c - 1), 1000);
|
||||
}
|
||||
return () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [cooldown]);
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setStatus(null);
|
||||
setError(msg);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
const showStatus = (msg: string) => {
|
||||
setError(null);
|
||||
setStatus(msg);
|
||||
setTimeout(() => setStatus(null), 4000);
|
||||
};
|
||||
|
||||
const handleDigitChange = (index: number, val: string) => {
|
||||
const newCode = [...code];
|
||||
newCode[index] = val;
|
||||
setCode(newCode);
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
const pastedData = e.clipboardData
|
||||
.getData('Text')
|
||||
.replace(/[^0-9]/g, '')
|
||||
.slice(0, 6);
|
||||
if (!pastedData) return;
|
||||
const newCode = [...code];
|
||||
for (let i = 0; i < pastedData.length; i++) {
|
||||
newCode[i] = pastedData[i];
|
||||
}
|
||||
setCode(newCode);
|
||||
};
|
||||
|
||||
const handleVerifyOtp = async () => {
|
||||
const token = code.join('');
|
||||
if (token.length !== 6) {
|
||||
showError('Please enter the full 6-digit code.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
showError(
|
||||
'We could not find the email used for sign up. Please go back and create your account again.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
|
||||
const { error: verifyError } = await supabase.auth.verifyOtp({
|
||||
email,
|
||||
token,
|
||||
type: 'email',
|
||||
});
|
||||
|
||||
if (verifyError) {
|
||||
showError(verifyError.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem('kumusha_pending_verification_email');
|
||||
showStatus('Email verified. Finishing your sign in...');
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
if (cooldown > 0) return;
|
||||
if (!email) {
|
||||
showError(
|
||||
'We could not find the email used for sign up. Please go back and create your account again.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
|
||||
const { error: resendError } = await supabase.auth.resend({
|
||||
type: 'signup',
|
||||
email,
|
||||
});
|
||||
|
||||
if (resendError) {
|
||||
showError(resendError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setCode(['', '', '', '', '', '']);
|
||||
setCooldown(60);
|
||||
showStatus('A new 6-digit code has been sent.');
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage style={{ backgroundColor: '#fafafa' }}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': 'transparent' }}>
|
||||
<IonButtons slot="start">
|
||||
<IonButton fill="clear" onClick={() => history.goBack()}>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle style={{ fontSize: '0px' }}>Verify email</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent
|
||||
className="auth-content"
|
||||
style={{
|
||||
'--background': 'linear-gradient(180deg, #fafafa 0%, #f4f0ff 100%)',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': '0px',
|
||||
'--padding-bottom': '0px',
|
||||
}}
|
||||
>
|
||||
<div className="auth-shell">
|
||||
<div
|
||||
className="auth-brand-block"
|
||||
style={{ gap: '12px', marginTop: '20px' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
borderRadius: '24px',
|
||||
backgroundColor: 'rgba(109,40,217,0.12)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={mailOutline}
|
||||
style={{ fontSize: '26px', color: '#6d28d9' }}
|
||||
/>
|
||||
</div>
|
||||
<h1 className="auth-heading" style={{ fontSize: '28px' }}>
|
||||
Check your email
|
||||
</h1>
|
||||
<p className="auth-subtitle" style={{ maxWidth: '340px' }}>
|
||||
{email ? (
|
||||
<>
|
||||
We sent a 6-digit code to <strong>{email}</strong>. Enter it
|
||||
below to confirm your account and continue.
|
||||
</>
|
||||
) : (
|
||||
'We could not find the email used for sign up. Go back and create your account again.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="auth-card auth-card--elevated">
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}
|
||||
>
|
||||
<OtpInputSlots
|
||||
value={code}
|
||||
onChange={handleDigitChange}
|
||||
onPaste={handlePaste}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{status ? (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(109,40,217,0.08)',
|
||||
color: '#6d28d9',
|
||||
padding: '12px 14px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="auth-inline-message">{error}</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
className="auth-submit-btn"
|
||||
onClick={handleVerifyOtp}
|
||||
disabled={loading || code.join('').length !== 6}
|
||||
>
|
||||
{loading ? 'Verifying...' : 'Verify Code'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={cooldown > 0 || loading}
|
||||
className="auth-link-button"
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
fontSize: '14px',
|
||||
padding: '4px 0',
|
||||
color: cooldown > 0 || loading ? '#9ca3af' : '#6d28d9',
|
||||
}}
|
||||
>
|
||||
{cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyEmailPage;
|
||||
@@ -1,317 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonInput,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from '@ionic/react';
|
||||
import { chevronBackOutline, lockClosedOutline } from 'ionicons/icons';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import OtpInputSlots from '../components/OtpInputSlots';
|
||||
import '../styles/auth.css';
|
||||
|
||||
interface LocationState {
|
||||
email?: string;
|
||||
}
|
||||
|
||||
const VerifyResetPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const location = useLocation<LocationState>();
|
||||
const { user, refreshProfile, profileStatus } = useAuth();
|
||||
const email = location.state?.email;
|
||||
|
||||
const [code, setCode] = useState<string[]>(['', '', '', '', '', '']);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const [passwordUpdated, setPasswordUpdated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!email) {
|
||||
history.replace('/forgot-password');
|
||||
}
|
||||
}, [email, history]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldown <= 0) return;
|
||||
const timer = setTimeout(() => setCooldown((current) => current - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [cooldown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && passwordUpdated && profileStatus !== 'loading') {
|
||||
loadProfileAndNavigate();
|
||||
}
|
||||
}, [user, passwordUpdated, profileStatus]);
|
||||
|
||||
const showMessage = (message: string, kind: 'error' | 'status') => {
|
||||
if (kind === 'error') {
|
||||
setError(message);
|
||||
setStatus(null);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
} else {
|
||||
setStatus(message);
|
||||
setError(null);
|
||||
setTimeout(() => setStatus(null), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
const loadProfileAndNavigate = async () => {
|
||||
if (!user) return;
|
||||
|
||||
const data = await refreshProfile();
|
||||
|
||||
if (
|
||||
!data ||
|
||||
!data.first_name ||
|
||||
!data.last_name ||
|
||||
!data.phone ||
|
||||
!data.country_of_residence
|
||||
) {
|
||||
history.replace('/setup-profile');
|
||||
} else {
|
||||
history.replace('/home');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDigitChange = (index: number, value: string) => {
|
||||
const nextCode = [...code];
|
||||
nextCode[index] = value.replace(/\D/g, '').slice(-1);
|
||||
setCode(nextCode);
|
||||
};
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
event.preventDefault();
|
||||
const digits = event.clipboardData
|
||||
.getData('Text')
|
||||
.replace(/\D/g, '')
|
||||
.slice(0, 6);
|
||||
if (!digits) return;
|
||||
|
||||
const nextCode = ['', '', '', '', '', ''];
|
||||
digits.split('').forEach((digit, index) => {
|
||||
nextCode[index] = digit;
|
||||
});
|
||||
setCode(nextCode);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const token = code.join('');
|
||||
if (token.length !== 6) {
|
||||
showMessage('Please enter the 6-digit reset code', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
showMessage('Password must be at least 6 characters', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
|
||||
const { error: verifyError } = await supabase.auth.verifyOtp({
|
||||
email: email!,
|
||||
token,
|
||||
type: 'recovery',
|
||||
});
|
||||
|
||||
if (verifyError) {
|
||||
showMessage(verifyError.message, 'error');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password: newPassword,
|
||||
});
|
||||
|
||||
if (updateError) {
|
||||
showMessage(updateError.message, 'error');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage('Password updated', 'status');
|
||||
setPasswordUpdated(true);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
if (cooldown > 0 || !email) return;
|
||||
|
||||
const { error: resendError } =
|
||||
await supabase.auth.resetPasswordForEmail(email);
|
||||
if (resendError) {
|
||||
showMessage(resendError.message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage('A new reset code has been sent', 'status');
|
||||
setCooldown(60);
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage style={{ backgroundColor: '#fafafa' }}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar
|
||||
style={
|
||||
{
|
||||
'--background': 'transparent',
|
||||
'--border-width': '0px',
|
||||
'--color': '#111827',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<IonButtons slot="start">
|
||||
<IonButton
|
||||
fill="clear"
|
||||
onClick={() => history.goBack()}
|
||||
style={
|
||||
{
|
||||
'--color': '#111827',
|
||||
'--border-radius': '12px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
aria-label="Go back"
|
||||
>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle style={{ fontSize: '18px', fontWeight: 700 }}>
|
||||
New password
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent
|
||||
className="auth-content"
|
||||
style={
|
||||
{
|
||||
'--background': 'linear-gradient(180deg, #fafafa 0%, #f4f0ff 100%)',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': '0px',
|
||||
'--padding-bottom': '0px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="auth-shell">
|
||||
<div
|
||||
className="auth-intro-block"
|
||||
style={{ alignItems: 'flex-start', textAlign: 'left' }}
|
||||
>
|
||||
<div className="auth-icon-container">
|
||||
<IonIcon icon={lockClosedOutline} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="auth-intro-heading">Enter your reset code</h1>
|
||||
<p className="auth-intro-body" style={{ marginTop: '8px' }}>
|
||||
Use the 6-digit code sent to <strong>{email}</strong>, then
|
||||
choose a new password.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="auth-form-card" style={{ marginBottom: '12px' }}>
|
||||
<OtpInputSlots
|
||||
value={code}
|
||||
onChange={handleDigitChange}
|
||||
onPaste={handlePaste}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={cooldown > 0 || loading}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
padding: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
color: cooldown > 0 ? '#9ca3af' : '#6d28d9',
|
||||
}}
|
||||
>
|
||||
{cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="auth-form-card">
|
||||
<IonInput
|
||||
type="password"
|
||||
label="New password"
|
||||
labelPlacement="floating"
|
||||
value={newPassword}
|
||||
onIonInput={(event) => setNewPassword(event.detail.value ?? '')}
|
||||
disabled={loading}
|
||||
placeholder="Enter a new password"
|
||||
style={
|
||||
{
|
||||
'--background': '#fafafa',
|
||||
'--border-radius': '12px',
|
||||
'--padding-start': '14px',
|
||||
'--padding-end': '14px',
|
||||
'--highlight-color-focused': '#6d28d9',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="auth-status-text" style={{ color: '#dc2626' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{status && (
|
||||
<p className="auth-status-text" style={{ color: '#16a34a' }}>
|
||||
{status}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<IonButton
|
||||
type="submit"
|
||||
expand="block"
|
||||
disabled={
|
||||
loading ||
|
||||
code.join('').length !== 6 ||
|
||||
newPassword.length < 6
|
||||
}
|
||||
style={
|
||||
{
|
||||
'--background': '#6d28d9',
|
||||
'--background-activated': '#5b21b6',
|
||||
'--border-radius': '999px',
|
||||
'--box-shadow': 'none',
|
||||
'--color': '#ffffff',
|
||||
height: '52px',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
marginTop: '4px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{loading ? 'Updating password...' : 'Update password'}
|
||||
</IonButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyResetPage;
|
||||
@@ -1,295 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonPage,
|
||||
IonSkeletonText,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
chevronBackOutline,
|
||||
copyOutline,
|
||||
listOutline,
|
||||
qrCodeOutline,
|
||||
shieldCheckmarkOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { useHistory, useLocation, useParams } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { formatMoney } from '../utils/formatMoney';
|
||||
import '../styles/support.css';
|
||||
|
||||
type Params = { id: string };
|
||||
|
||||
type RedemptionEntry = {
|
||||
id: string;
|
||||
redeemed_amount: number;
|
||||
redeemed_at: string;
|
||||
merchant_name: string | null;
|
||||
status: string | null;
|
||||
};
|
||||
|
||||
type Voucher = {
|
||||
id: string;
|
||||
voucher_code: string;
|
||||
status: string;
|
||||
expires_at: string;
|
||||
qr_payload: string;
|
||||
voucher_type: string;
|
||||
redeemed_amount?: number;
|
||||
remaining_amount?: number;
|
||||
voucher_redemptions?: RedemptionEntry[] | null;
|
||||
support_orders?: {
|
||||
service_type: string;
|
||||
amount: number;
|
||||
recipients?: { first_name: string; last_name: string } | null;
|
||||
merchants?: { name: string; branch_name: string | null } | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const VoucherDetailPage: React.FC = () => {
|
||||
const { id } = useParams<Params>();
|
||||
const history = useHistory();
|
||||
const location = useLocation<{ parentRoot?: string }>();
|
||||
const { user } = useAuth();
|
||||
const [voucher, setVoucher] = useState<Voucher | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
void loadVoucher();
|
||||
});
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (history.length > 1) {
|
||||
history.goBack();
|
||||
} else {
|
||||
history.replace('/activity');
|
||||
}
|
||||
};
|
||||
|
||||
const parentRoot = location.state?.parentRoot ?? '/activity';
|
||||
|
||||
const handleGoToParentRoot = () => {
|
||||
history.replace(parentRoot);
|
||||
};
|
||||
|
||||
const showMessage = (text: string) => {
|
||||
setMessage(text);
|
||||
setTimeout(() => setMessage(null), 4000);
|
||||
};
|
||||
|
||||
const loadVoucher = async () => {
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('vouchers')
|
||||
.select(
|
||||
'id,voucher_code,status,expires_at,qr_payload,voucher_type,redeemed_amount,remaining_amount,voucher_redemptions(id,redeemed_amount,redeemed_at,merchant_name,status),support_orders!inner(service_type,amount,user_id,recipients(first_name,last_name),merchants(name,branch_name))'
|
||||
)
|
||||
.eq('id', id)
|
||||
.eq('support_orders.user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
showMessage(error?.message ?? 'Voucher not found');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setVoucher(data as unknown as Voucher);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleCopyCode = async () => {
|
||||
if (!voucher) return;
|
||||
await navigator.clipboard.writeText(voucher.voucher_code);
|
||||
showMessage('Voucher code copied');
|
||||
};
|
||||
|
||||
const totalAmount = Number(voucher?.support_orders?.amount ?? 0);
|
||||
const redeemedAmount = Number(voucher?.redeemed_amount ?? 0);
|
||||
const fallbackRemaining = Math.max(totalAmount - redeemedAmount, 0);
|
||||
const remainingAmount = Number(
|
||||
voucher?.remaining_amount ?? fallbackRemaining
|
||||
);
|
||||
const hasPartialRedemption = redeemedAmount > 0 && remainingAmount > 0;
|
||||
const redemptionHistory = [...(voucher?.voucher_redemptions ?? [])].sort(
|
||||
(a, b) =>
|
||||
new Date(b.redeemed_at).getTime() - new Date(a.redeemed_at).getTime()
|
||||
);
|
||||
|
||||
return (
|
||||
<IonPage style={{ backgroundColor: '#fafafa' }}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar
|
||||
style={
|
||||
{
|
||||
'--background': 'transparent',
|
||||
'--border-width': '0px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<IonButtons slot="start">
|
||||
<IonButton fill="clear" onClick={handleGoBack} aria-label="Go back">
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle style={{ fontSize: 18, fontWeight: 700 }}>Voucher</IonTitle>
|
||||
<IonButtons slot="end">
|
||||
<IonButton
|
||||
fill="clear"
|
||||
onClick={handleGoToParentRoot}
|
||||
aria-label="Back to activity"
|
||||
>
|
||||
<IonIcon icon={listOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent
|
||||
style={
|
||||
{
|
||||
'--background': 'linear-gradient(180deg, #fafafa 0%, #f4f0ff 100%)',
|
||||
'--padding-top': '8px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="voucher-hero-card">
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: 160, height: 32, borderRadius: 999 }}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: 220, height: 220, borderRadius: 24 }}
|
||||
/>
|
||||
</div>
|
||||
) : voucher ? (
|
||||
<>
|
||||
{message && (
|
||||
<p
|
||||
style={{ margin: '12px 20px', color: '#6d28d9', fontSize: 13 }}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
<div className="voucher-hero-card">
|
||||
<span className="vhc-code-pill">{voucher.voucher_code}</span>
|
||||
<div className="vhc-qr-placeholder">
|
||||
<IonIcon icon={qrCodeOutline} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="vhc-expiry">
|
||||
Expires {new Date(voucher.expires_at).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="vhc-merchant">
|
||||
{voucher.support_orders?.merchants
|
||||
? `${voucher.support_orders.merchants.name}${voucher.support_orders.merchants.branch_name ? ` — ${voucher.support_orders.merchants.branch_name}` : ''}`
|
||||
: `Kumusha ${voucher.support_orders?.service_type === 'medication' ? 'pharmacy' : 'grocery'} partner`}
|
||||
</p>
|
||||
<p className="vhc-merchant">
|
||||
For{' '}
|
||||
{voucher.support_orders?.recipients
|
||||
? `${voucher.support_orders.recipients.first_name} ${voucher.support_orders.recipients.last_name}`
|
||||
: 'recipient'}{' '}
|
||||
· {formatMoney(Number(voucher.support_orders?.amount ?? 0))}
|
||||
</p>
|
||||
</div>
|
||||
<IonButton
|
||||
onClick={handleCopyCode}
|
||||
style={
|
||||
{
|
||||
'--background': '#6d28d9',
|
||||
'--border-radius': '999px',
|
||||
'--box-shadow': 'none',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<IonIcon icon={copyOutline} slot="start" />
|
||||
Copy code
|
||||
</IonButton>
|
||||
</div>
|
||||
|
||||
{(hasPartialRedemption || redemptionHistory.length > 0) && (
|
||||
<div className="voucher-balance-card">
|
||||
<div className="voucher-balance-head">
|
||||
<div>
|
||||
<p className="voucher-balance-title">Voucher balance</p>
|
||||
<p className="voucher-balance-subtitle">
|
||||
Track redeemed and remaining value for this voucher.
|
||||
</p>
|
||||
</div>
|
||||
<div className="voucher-balance-total">
|
||||
{formatMoney(totalAmount)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="voucher-balance-pills">
|
||||
<div className="voucher-balance-pill is-redeemed">
|
||||
Redeemed {formatMoney(redeemedAmount)}
|
||||
</div>
|
||||
<div className="voucher-balance-pill is-remaining">
|
||||
Unredeemed {formatMoney(remainingAmount)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{redemptionHistory.length > 0 && (
|
||||
<div className="voucher-redemption-history">
|
||||
<p className="voucher-redemption-history-title">
|
||||
Redemption history
|
||||
</p>
|
||||
{redemptionHistory.map((entry) => (
|
||||
<div key={entry.id} className="voucher-redemption-row">
|
||||
<div>
|
||||
<p className="voucher-redemption-merchant">
|
||||
{entry.merchant_name ||
|
||||
voucher.support_orders?.merchants?.name ||
|
||||
`Kumusha ${voucher.support_orders?.service_type === 'medication' ? 'pharmacy' : 'grocery'} partner`}
|
||||
</p>
|
||||
<p className="voucher-redemption-date">
|
||||
{new Date(entry.redeemed_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="voucher-redemption-right">
|
||||
<p className="voucher-redemption-amount">
|
||||
{formatMoney(entry.redeemed_amount)}
|
||||
</p>
|
||||
<span className="voucher-redemption-status">
|
||||
{entry.status || 'Redeemed'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="security-note-card">
|
||||
<IonIcon icon={shieldCheckmarkOutline} className="snc-icon" />
|
||||
<p className="snc-text">
|
||||
Redeemable at any approved Kumusha{' '}
|
||||
{voucher.support_orders?.service_type === 'medication'
|
||||
? 'pharmacy'
|
||||
: 'grocery'}{' '}
|
||||
partner. Balance updates automatically after partial redemption.
|
||||
Do not share code.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoucherDetailPage;
|
||||
@@ -1,507 +0,0 @@
|
||||
/* Activity Feed */
|
||||
.activity-top-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin: 0 20px 14px;
|
||||
}
|
||||
|
||||
.activity-title-block {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.activity-page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
color: #171827;
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.activity-page-subtitle {
|
||||
margin: 0;
|
||||
color: rgba(23, 24, 39, 0.56);
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.activity-shell {
|
||||
padding-bottom: calc(130px + var(--ion-safe-area-bottom, 0px));
|
||||
}
|
||||
|
||||
.activity-type-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 20px 12px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.activity-type-filter-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-type-chip {
|
||||
flex: 0 0 auto;
|
||||
background: #ffffff;
|
||||
color: rgba(23, 24, 39, 0.58);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 9px 16px;
|
||||
margin: 0;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.activity-type-chip.active {
|
||||
background: #6d28d9;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.activity-filter-row {
|
||||
margin: 0 20px 18px;
|
||||
}
|
||||
|
||||
.activity-range-shell {
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border-radius: 24px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.activity-range-segment {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.activity-range-segment ion-segment-button {
|
||||
min-height: 46px;
|
||||
--background: transparent;
|
||||
--background-checked: #6d28d9;
|
||||
--color: rgba(23, 24, 39, 0.5);
|
||||
--color-checked: #ffffff;
|
||||
--indicator-color: transparent;
|
||||
--border-radius: 999px;
|
||||
--padding-start: 2px;
|
||||
--padding-end: 2px;
|
||||
text-transform: none;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.activity-range-segment ion-label {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.activity-grouped-list {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-radius: 24px;
|
||||
padding: 6px 0;
|
||||
margin: 0 20px 20px;
|
||||
}
|
||||
|
||||
.activity-date-group-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: rgba(23, 24, 39, 0.38);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 16px 20px 8px;
|
||||
}
|
||||
|
||||
.activity-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.activity-feed-item {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.activity-feed-item + .activity-feed-item {
|
||||
border-top: 1px solid rgba(23, 24, 39, 0.06);
|
||||
}
|
||||
|
||||
.activity-feed-item:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.activity-feed-item:active {
|
||||
background: rgba(109, 40, 217, 0.03);
|
||||
}
|
||||
|
||||
.activity-feed-avatar-wrap {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.activity-feed-avatar {
|
||||
position: relative;
|
||||
width: 53px;
|
||||
height: 53px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #171827;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.activity-feed-avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.activity-feed-avatar-lavender {
|
||||
background: #efe7fb;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.activity-feed-avatar-mint {
|
||||
background: #e8f7ee;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.activity-feed-avatar-sky {
|
||||
background: #eaf3ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.activity-feed-avatar-gold {
|
||||
background: #fff2df;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.activity-feed-avatar-brand {
|
||||
background: #ece9ff;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.activity-feed-avatar-badge {
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 6px 14px rgba(23, 24, 39, 0.12);
|
||||
}
|
||||
|
||||
.activity-feed-avatar-badge-grocery {
|
||||
background: #f1e7fb;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.activity-feed-avatar-badge-medication {
|
||||
background: #e8f7ee;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.activity-feed-avatar-badge-airtime {
|
||||
background: #eaf3ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.activity-feed-avatar-badge-electricity {
|
||||
background: #fff2df;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.activity-feed-avatar-badge ion-icon {
|
||||
font-size: 13px;
|
||||
--ionicon-stroke-width: 48px;
|
||||
}
|
||||
|
||||
.activity-feed-badge-image {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-feed-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.activity-feed-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(128px, 1fr) auto;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.activity-feed-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.activity-feed-title {
|
||||
margin: 0;
|
||||
color: #171827;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.activity-feed-subtitle {
|
||||
margin: 0;
|
||||
color: rgba(23, 24, 39, 0.54);
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.activity-feed-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
min-width: 112px;
|
||||
}
|
||||
|
||||
.activity-feed-amount {
|
||||
margin: 0;
|
||||
color: #171827;
|
||||
font-size: 14px;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.activity-feed-status {
|
||||
width: fit-content;
|
||||
min-height: 28px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0 10px 0 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: capitalize;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.activity-feed-status ion-icon {
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-feed-status-success {
|
||||
background: rgba(22, 163, 74, 0.1);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.activity-feed-status-warning {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.activity-feed-status-partial {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.activity-feed-breakdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.activity-feed-breakdown-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.activity-feed-breakdown-pill {
|
||||
min-height: 24px;
|
||||
border-radius: 999px;
|
||||
padding: 0 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.activity-feed-breakdown-pill ion-icon {
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-feed-breakdown-pill.is-redeemed {
|
||||
background: rgba(22, 163, 74, 0.1);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.activity-feed-breakdown-pill.is-remaining {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.activity-feed-usage-note {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
color: rgba(23, 24, 39, 0.56);
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Notifications List */
|
||||
.notifications-list-card {
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
padding: 8px 0;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.notification-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 16px 20px;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nr-status-dot {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
left: 8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.nr-status-dot.is-unread {
|
||||
background: #6d28d9;
|
||||
}
|
||||
|
||||
.nr-status-dot.is-read {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.nr-icon-box {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nr-icon-box img {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nr-icon-box ion-icon {
|
||||
font-size: 21px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nr-icon-box.is-read {
|
||||
opacity: 0.64;
|
||||
}
|
||||
|
||||
/* Support type mappings */
|
||||
.nr-icon-box.notification-service-grocery {
|
||||
background: rgba(109, 40, 217, 0.1);
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.nr-icon-box.notification-service-medication {
|
||||
background: rgba(22, 163, 74, 0.1);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.nr-icon-box.notification-service-airtime {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.nr-icon-box.notification-service-electricity {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.nr-icon-box.notification-service-support {
|
||||
background: rgba(109, 40, 217, 0.1);
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.nr-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nr-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-dark);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.nr-body {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--ion-color-medium);
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.nr-time {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.nr-link-icon {
|
||||
font-size: 18px;
|
||||
color: var(--ion-color-medium);
|
||||
align-self: center;
|
||||
}
|
||||
@@ -1,959 +0,0 @@
|
||||
.auth-page-shell,
|
||||
.auth-shell {
|
||||
padding: 18px 20px calc(var(--ion-safe-area-bottom, 0px) + 28px);
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
--background: linear-gradient(180deg, #fafafa 0%, #f6f1ff 52%, #f4f0ff 100%);
|
||||
}
|
||||
|
||||
.auth-shell--centered {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.auth-brand-block,
|
||||
.auth-brand-intro {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 44px 0 0 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-brand-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.auth-brand-name {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #6d28d9;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-card--elevated {
|
||||
box-shadow:
|
||||
0 24px 60px rgba(109, 40, 217, 0.1),
|
||||
0 8px 20px rgba(17, 24, 39, 0.05);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.auth-social-buttons,
|
||||
.auth-form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.auth-form-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
color: rgba(17, 24, 39, 0.45);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
margin: 20px 0 16px;
|
||||
}
|
||||
|
||||
.auth-divider span {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
padding: 0 12px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.auth-divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: rgba(17, 24, 39, 0.08);
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auth-field-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(17, 24, 39, 0.66);
|
||||
}
|
||||
|
||||
.auth-text-input {
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||
border-radius: 12px;
|
||||
background: #f8f7fb;
|
||||
font-size: 15px;
|
||||
color: #111827;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.auth-text-input::placeholder {
|
||||
color: rgba(17, 24, 39, 0.38);
|
||||
}
|
||||
|
||||
.auth-text-input:focus {
|
||||
border-color: rgba(109, 40, 217, 0.4);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 4px rgba(109, 40, 217, 0.08);
|
||||
}
|
||||
|
||||
.auth-text-input:disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.auth-text-input.has-error {
|
||||
border-color: rgba(220, 38, 38, 0.28);
|
||||
background: rgba(254, 242, 242, 0.8);
|
||||
}
|
||||
|
||||
.auth-password-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-password-wrap .auth-text-input {
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.auth-password-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
transform: translateY(-50%);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(17, 24, 39, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.auth-password-toggle ion-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.auth-field-error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.auth-submit-btn {
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #6d28d9;
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.auth-submit-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-inline-message {
|
||||
border-radius: 12px;
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
color: #dc2626;
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-meta-hint {
|
||||
font-size: 12px;
|
||||
color: rgba(17, 24, 39, 0.45);
|
||||
}
|
||||
|
||||
.auth-link-button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: #6d28d9;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-eyebrow {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #6d28d9;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.auth-heading {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: rgba(17, 24, 39, 0.62);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-mode-toggle {
|
||||
background: #f7f4fb;
|
||||
border-radius: 999px;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auth-toggle-pill {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 11px 12px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-toggle-pill.active {
|
||||
background: #ffffff;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
box-shadow: 0 8px 18px rgba(17, 24, 39, 0.06);
|
||||
}
|
||||
|
||||
.auth-toggle-pill.inactive {
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
color: rgba(17, 24, 39, 0.5);
|
||||
}
|
||||
|
||||
.social-auth-button {
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: 0 16px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #f8f7fb;
|
||||
color: #111827;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.social-auth-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.social-auth-button__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.social-auth-button__label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.login,
|
||||
.register {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.otp-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.otp-slot {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.avatar-picker-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-preview-circle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-form-card {
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.auth-field {
|
||||
--background: #fafafa;
|
||||
--color: #111827;
|
||||
--placeholder-color: rgba(17, 24, 39, 0.38);
|
||||
--placeholder-opacity: 1;
|
||||
--highlight-color-focused: #6d28d9;
|
||||
--border-radius: 12px;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.auth-field::part(label) {
|
||||
color: rgba(17, 24, 39, 0.62);
|
||||
}
|
||||
|
||||
.auth-field.ion-focused::part(label) {
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.auth-helper-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--ion-color-danger);
|
||||
margin-left: 16px;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.auth-status-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.auth-primary-btn {
|
||||
--background: var(--ion-color-primary);
|
||||
--border-radius: 999px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin-top: 8px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.auth-social-btn {
|
||||
--background: #fafafa;
|
||||
--border-radius: 999px;
|
||||
--box-shadow: none;
|
||||
--color: var(--ion-color-dark);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
background: transparent;
|
||||
margin: 16px 0 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-footer-text {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--ion-color-medium);
|
||||
}
|
||||
|
||||
.auth-footer-link {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-intro-block {
|
||||
background: transparent;
|
||||
margin: 8px 0 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.auth-icon-container {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 24px;
|
||||
background: rgba(109, 40, 217, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ion-color-primary);
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.auth-intro-heading {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-dark);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-intro-body {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: var(--ion-color-medium);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-otp-row {
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
margin: 0 0 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.auth-otp-input {
|
||||
width: calc((100% - 60px) / 6);
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: #fafafa;
|
||||
border: 1px solid transparent;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-dark);
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.auth-otp-input:focus {
|
||||
outline: none;
|
||||
background: rgba(109, 40, 217, 0.1);
|
||||
border-color: var(--ion-color-primary);
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
|
||||
.auth-action-area {
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-resend-btn {
|
||||
--background: transparent;
|
||||
--color: var(--ion-color-primary);
|
||||
--box-shadow: none;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-resend-btn[disabled] {
|
||||
--color: #9ca3af;
|
||||
}
|
||||
|
||||
.auth-success-card {
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auth-success-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-dark);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-success-body {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--ion-color-medium);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.setup-avatar-container {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 24px;
|
||||
background: rgba(109, 40, 217, 0.1);
|
||||
margin: 0 auto 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.setup-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.setup-avatar-icon {
|
||||
font-size: 32px;
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
|
||||
.setup-avatar-input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setup-profile-actions-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.setup-profile-actions-row ion-button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.setup-profile-secondary-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 112px;
|
||||
height: 52px;
|
||||
--border-radius: 999px;
|
||||
--box-shadow: none;
|
||||
--border-color: rgba(109, 40, 217, 0.18);
|
||||
--border-width: 1px;
|
||||
--color: #6d28d9;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.setup-profile-primary-btn {
|
||||
flex: 1;
|
||||
height: 52px;
|
||||
--background: #6d28d9;
|
||||
--background-activated: #5b21b6;
|
||||
--border-radius: 999px;
|
||||
--box-shadow: none;
|
||||
--color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-keep-signed-in {
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
background: #f8f7fb;
|
||||
border-radius: 12px;
|
||||
padding: 0 16px;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.auth-keep-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.auth-keep-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.auth-keep-subtitle {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: rgba(17, 24, 39, 0.55);
|
||||
}
|
||||
|
||||
.auth-keep-check {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(17, 24, 39, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-keep-check ion-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.auth-keep-check.active {
|
||||
background: #6d28d9;
|
||||
border-color: #6d28d9;
|
||||
}
|
||||
|
||||
.auth-splash-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.auth-splash-overlay ion-page,
|
||||
.auth-splash-overlay ion-content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.app-splash-shell {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: calc(var(--ion-safe-area-top, 0px) + 18px) 20px
|
||||
calc(var(--ion-safe-area-bottom, 0px) + 28px);
|
||||
}
|
||||
|
||||
.app-splash-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: transparent;
|
||||
border-radius: 24px;
|
||||
padding: 32px 24px;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-splash-logo-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 380px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.app-splash-category-orbit {
|
||||
position: absolute;
|
||||
width: 61px;
|
||||
height: 61px;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 12px 26px rgba(109, 40, 217, 0.12);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-top: -30.5px;
|
||||
margin-left: -30.5px;
|
||||
}
|
||||
|
||||
.app-splash-category-orbit ion-icon {
|
||||
font-size: 31px;
|
||||
}
|
||||
|
||||
.app-splash-category-image {
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.app-splash-category-orbit--groceries {
|
||||
background: rgba(109, 40, 217, 0.12);
|
||||
color: #6d28d9;
|
||||
animation: app-splash-orbit-one 5.2s linear infinite;
|
||||
}
|
||||
|
||||
.app-splash-category-orbit--medicine {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #16a34a;
|
||||
animation: app-splash-orbit-two 6.1s linear infinite;
|
||||
}
|
||||
|
||||
.app-splash-category-orbit--airtime {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #2563eb;
|
||||
animation: app-splash-orbit-three 5.7s linear infinite;
|
||||
}
|
||||
|
||||
.app-splash-category-orbit--electricity {
|
||||
background: rgba(251, 191, 36, 0.14);
|
||||
color: #d97706;
|
||||
animation: app-splash-orbit-four 6.4s linear infinite;
|
||||
}
|
||||
|
||||
.app-splash-logo-glow {
|
||||
position: absolute;
|
||||
width: 101px;
|
||||
height: 101px;
|
||||
border-radius: 35px;
|
||||
background: rgba(109, 40, 217, 0.14);
|
||||
filter: blur(18px);
|
||||
animation: app-splash-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.app-splash-logo-circle {
|
||||
position: relative;
|
||||
width: 79px;
|
||||
height: 79px;
|
||||
border-radius: 26px;
|
||||
background: rgba(109, 40, 217, 0.12);
|
||||
color: #6d28d9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 37px;
|
||||
animation: app-splash-heart-float 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.app-splash-logo-circle ion-icon {
|
||||
animation: app-splash-heart-beat 1.45s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.app-splash-heart-ripple {
|
||||
position: absolute;
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 31px;
|
||||
border: 1.5px solid rgba(109, 40, 217, 0.16);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.app-splash-heart-ripple--one {
|
||||
animation: app-splash-ripple 2.4s ease-out infinite;
|
||||
}
|
||||
|
||||
.app-splash-heart-ripple--two {
|
||||
animation: app-splash-ripple 2.4s ease-out 1.1s infinite;
|
||||
}
|
||||
|
||||
.app-splash-brand {
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #6d28d9;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.app-splash-title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #111827;
|
||||
margin: 0 0 48px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.app-splash-subtitle {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: rgba(17, 24, 39, 0.62);
|
||||
margin: 0 0 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-splash-progress-track {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(109, 40, 217, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-splash-progress-label {
|
||||
margin: 12px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(17, 24, 39, 0.5);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.app-splash-progress-bar {
|
||||
display: block;
|
||||
width: 42%;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #6d28d9 0%, #8b5cf6 100%);
|
||||
animation: app-splash-slide 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes app-splash-slide {
|
||||
0% {
|
||||
transform: translateX(-110%);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(140%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(140%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-splash-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.65;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-splash-heart-beat {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
18% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
34% {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
48% {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
64% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-splash-heart-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-splash-ripple {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.88);
|
||||
}
|
||||
24% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(1.28);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-splash-orbit-one {
|
||||
from {
|
||||
transform: rotate(0deg) translateX(131px) rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg) translateX(131px) rotate(-360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-splash-orbit-two {
|
||||
from {
|
||||
transform: rotate(360deg) translateX(149px) rotate(-360deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(0deg) translateX(149px) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-splash-orbit-three {
|
||||
from {
|
||||
transform: rotate(120deg) translateX(140px) rotate(-120deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(480deg) translateX(140px) rotate(-480deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-splash-orbit-four {
|
||||
from {
|
||||
transform: rotate(220deg) translateX(153px) rotate(-220deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(-140deg) translateX(153px) rotate(140deg);
|
||||
}
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
/* Profile Tab */
|
||||
.profile-shell {
|
||||
padding-bottom: calc(130px + var(--ion-safe-area-bottom, 0px));
|
||||
}
|
||||
|
||||
.profile-page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-dark);
|
||||
margin: 0 20px 16px;
|
||||
}
|
||||
|
||||
.profile-summary-card {
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
margin: 0 20px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.psc-avatar-shell {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 24px;
|
||||
background: rgba(109, 40, 217, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.psc-avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 24px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.psc-avatar-placeholder {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 24px;
|
||||
background: rgba(109, 40, 217, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ion-color-primary);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.psc-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.psc-name {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-dark);
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.psc-location {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--ion-color-medium);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.psc-edit-btn {
|
||||
--background: #fafafa;
|
||||
--color: var(--ion-color-dark);
|
||||
--border-radius: 12px;
|
||||
--box-shadow: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-quick-actions-card {
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
padding: 8px 0;
|
||||
margin: 0 20px 24px;
|
||||
}
|
||||
|
||||
.profile-quick-action {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-quick-action + .profile-quick-action {
|
||||
border-top: 1px solid rgba(17, 24, 39, 0.06);
|
||||
}
|
||||
|
||||
.profile-quick-action-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-quick-action-icon--accent {
|
||||
background: rgba(109, 40, 217, 0.1);
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.profile-quick-action-icon--soft {
|
||||
background: rgba(22, 163, 74, 0.1);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.profile-quick-action-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-quick-action-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.profile-quick-action-subtitle {
|
||||
font-size: 13px;
|
||||
color: rgba(17, 24, 39, 0.6);
|
||||
}
|
||||
|
||||
/* Settings Cards */
|
||||
.settings-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: rgba(17, 24, 39, 0.48);
|
||||
margin: 0 20px 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
padding: 8px 0;
|
||||
margin: 0 20px 20px;
|
||||
}
|
||||
|
||||
.profile-footer-mark {
|
||||
margin: 8px 20px 0;
|
||||
padding: 12px 20px calc(8px + var(--ion-safe-area-bottom, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-footer-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 16px;
|
||||
background: rgba(109, 40, 217, 0.1);
|
||||
color: #6d28d9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-footer-name {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.profile-footer-meta {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(17, 24, 39, 0.48);
|
||||
}
|
||||
|
||||
.profile-footer-copyright {
|
||||
margin: 2px 0 0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: rgba(17, 24, 39, 0.4);
|
||||
}
|
||||
|
||||
.profile-inline-note {
|
||||
margin: 0 16px 10px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(109, 40, 217, 0.06);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-inline-note-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 12px;
|
||||
background: rgba(109, 40, 217, 0.12);
|
||||
color: #6d28d9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-inline-note-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.profile-inline-note-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: rgba(17, 24, 39, 0.6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 56px;
|
||||
padding: 8px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sr-icon-box {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
|
||||
.sr-icon-box.danger {
|
||||
color: var(--ion-color-danger);
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
}
|
||||
|
||||
.sr-label {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ion-color-dark);
|
||||
}
|
||||
|
||||
.sr-label.danger {
|
||||
color: var(--ion-color-danger);
|
||||
}
|
||||
|
||||
.sr-toggle {
|
||||
--handle-background: #ffffff;
|
||||
--handle-background-checked: #ffffff;
|
||||
--track-background: #e5e7eb;
|
||||
--track-background-checked: var(--ion-color-primary);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Edit Profile */
|
||||
.edit-profile-card {
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
margin: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.profile-back-button {
|
||||
--color: var(--profile-action-accent, var(--color-brand));
|
||||
--background: transparent;
|
||||
--background-activated: transparent;
|
||||
--border-radius: 12px;
|
||||
--box-shadow: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.profile-back-button ion-icon {
|
||||
color: var(--profile-action-accent, var(--color-brand));
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.edit-profile-shell {
|
||||
padding-bottom: calc(32px + var(--ion-safe-area-bottom, 0px));
|
||||
}
|
||||
|
||||
.epc-field {
|
||||
--background: #fafafa;
|
||||
--color: #111827;
|
||||
--placeholder-color: rgba(17, 24, 39, 0.38);
|
||||
--placeholder-opacity: 1;
|
||||
--highlight-color-focused: #6d28d9;
|
||||
--border-radius: 12px;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.epc-field::part(label) {
|
||||
color: rgba(17, 24, 39, 0.62);
|
||||
}
|
||||
|
||||
.epc-field.ion-focused::part(label) {
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.epc-save-btn {
|
||||
--background: var(--ion-color-primary);
|
||||
--border-radius: 999px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin: 0 20px 20px;
|
||||
height: 52px;
|
||||
}
|
||||
@@ -1,949 +0,0 @@
|
||||
/* Support Flow */
|
||||
.support-toolbar {
|
||||
--background: var(--flow-accent-soft, #fafafa);
|
||||
--border-width: 0px;
|
||||
--min-height: 58px;
|
||||
--padding-start: 8px;
|
||||
--padding-end: 20px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.support-toolbar-title {
|
||||
color: var(--color-text-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.support-toolbar-title .toolbar-title,
|
||||
.support-summary-toolbar-title .toolbar-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.support-summary-toolbar-title::part(title) {
|
||||
color: #171827 !important;
|
||||
}
|
||||
|
||||
.support-summary-toolbar-title {
|
||||
color: #171827 !important;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.support-toolbar-service-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 12px;
|
||||
background: var(--flow-icon-soft, rgba(109, 40, 217, 0.12));
|
||||
color: var(--flow-accent, #6d28d9);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.support-toolbar-service-icon ion-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.support-toolbar-service-image {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.support-back-button {
|
||||
--color: var(--support-action-accent, var(--color-brand));
|
||||
--background: transparent;
|
||||
--background-activated: transparent;
|
||||
--border-radius: 12px;
|
||||
--box-shadow: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.support-back-button ion-icon {
|
||||
color: var(--support-action-accent, var(--color-brand));
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.order-detail-root-escape-button {
|
||||
--color: var(--root-escape-accent, var(--color-text-primary));
|
||||
--background: transparent;
|
||||
--background-activated: transparent;
|
||||
}
|
||||
|
||||
.order-detail-root-escape-button ion-icon {
|
||||
color: var(--root-escape-accent, var(--color-text-primary));
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.order-detail-root-escape-image {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-step-indicator {
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
padding: 14px 16px;
|
||||
margin: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ssi-chip {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
background: #fafafa;
|
||||
color: var(--ion-color-medium);
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ssi-chip:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ssi-chip.active {
|
||||
background: var(--flow-accent, var(--ion-color-primary));
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.ssi-chip.completed {
|
||||
background: var(--flow-accent-soft, rgba(109, 40, 217, 0.1));
|
||||
color: var(--flow-accent, var(--ion-color-primary));
|
||||
}
|
||||
|
||||
.ssi-line {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: #f4f5f5;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.ssi-line.active {
|
||||
background: var(--flow-accent, rgba(109, 40, 217, 0.5));
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.support-form-card {
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
margin: 0 20px 12px;
|
||||
}
|
||||
|
||||
.support-form-card-themed {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--flow-card-soft, #ffffff) 0%,
|
||||
#ffffff 58%
|
||||
);
|
||||
}
|
||||
|
||||
.support-select,
|
||||
.support-text-input {
|
||||
--background: #fafafa;
|
||||
--color: var(--color-text-primary);
|
||||
--placeholder-color: var(--color-text-secondary);
|
||||
--placeholder-opacity: 1;
|
||||
--border-radius: 12px;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
min-height: 54px;
|
||||
border-radius: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.support-payment-select-wrap {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.support-payment-select-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #171827;
|
||||
}
|
||||
|
||||
.support-payment-trigger-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.support-payment-trigger-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #171827;
|
||||
}
|
||||
|
||||
.support-payment-trigger-detail {
|
||||
font-size: 12px;
|
||||
color: rgba(23, 24, 39, 0.56);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.support-payment-preview,
|
||||
.support-payment-sheet-option {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
background: #fafafa;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.support-payment-preview-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.support-payment-preview-chevron {
|
||||
flex-shrink: 0;
|
||||
color: rgba(23, 24, 39, 0.5);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.support-payment-modal {
|
||||
--border-radius: 18px 18px 0 0;
|
||||
}
|
||||
|
||||
.support-payment-modal::part(content) {
|
||||
border-radius: 18px 18px 0 0;
|
||||
}
|
||||
|
||||
.support-payment-modal::part(backdrop) {
|
||||
border-radius: 18px 18px 0 0;
|
||||
}
|
||||
|
||||
.support-payment-sheet-content {
|
||||
--padding-bottom: calc(40px + var(--ion-safe-area-bottom));
|
||||
}
|
||||
|
||||
.support-payment-sheet {
|
||||
padding: 12px 20px calc(56px + var(--ion-safe-area-bottom));
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.support-payment-sheet-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.support-payment-sheet-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #171827;
|
||||
}
|
||||
|
||||
.support-payment-sheet-subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: rgba(23, 24, 39, 0.56);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.support-payment-sheet-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-payment-sheet-close ion-icon {
|
||||
font-size: 20px;
|
||||
color: #171827;
|
||||
}
|
||||
|
||||
.support-payment-sheet-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
background: transparent;
|
||||
padding: 0 0 calc(132px + var(--ion-safe-area-bottom));
|
||||
margin-bottom: calc(24px + var(--ion-safe-area-bottom));
|
||||
}
|
||||
|
||||
.support-payment-sheet-option {
|
||||
box-shadow: inset 0 0 0 1px rgba(23, 24, 39, 0.04);
|
||||
}
|
||||
|
||||
.support-payment-sheet-option.selected {
|
||||
background: rgba(109, 40, 217, 0.08);
|
||||
box-shadow: inset 0 0 0 1.5px rgba(109, 40, 217, 0.18);
|
||||
}
|
||||
|
||||
.support-payment-selected-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: #6d28d9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-payment-method-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.support-payment-method-icon ion-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.support-google-pay-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.support-google-pay-g {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-google-pay-pay {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #3c4043;
|
||||
letter-spacing: -0.4px;
|
||||
}
|
||||
|
||||
.support-payment-method-icon-card {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(109, 40, 217, 0.14),
|
||||
rgba(124, 58, 237, 0.22)
|
||||
);
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.support-payment-method-icon-apple {
|
||||
background: linear-gradient(135deg, #101828, #1f2937);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.support-payment-method-icon-apple ion-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.support-payment-method-icon-google {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f3f6fb 100%);
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.05);
|
||||
color: #4285f4;
|
||||
}
|
||||
|
||||
.support-payment-method-icon-paypal {
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
color: #003087;
|
||||
}
|
||||
|
||||
.support-payment-method-icon-paypal ion-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.support-payment-sheet-option:last-child {
|
||||
margin-bottom: calc(64px + var(--ion-safe-area-bottom));
|
||||
}
|
||||
|
||||
.support-recipient-trigger {
|
||||
width: 100%;
|
||||
min-height: 76px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #fafafa;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.support-recipient-trigger-left,
|
||||
.support-recipient-sheet-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.support-recipient-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(109, 40, 217, 0.12);
|
||||
}
|
||||
|
||||
.support-recipient-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.support-recipient-avatar-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.support-recipient-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.support-recipient-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #171827;
|
||||
}
|
||||
|
||||
.support-recipient-hint {
|
||||
font-size: 12px;
|
||||
color: rgba(23, 24, 39, 0.56);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.support-recipient-chevron {
|
||||
flex-shrink: 0;
|
||||
color: rgba(23, 24, 39, 0.5);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.support-recipient-modal {
|
||||
--border-radius: 18px 18px 0 0;
|
||||
}
|
||||
|
||||
.support-recipient-modal::part(content) {
|
||||
border-radius: 18px 18px 0 0;
|
||||
}
|
||||
|
||||
.support-recipient-modal::part(backdrop) {
|
||||
border-radius: 18px 18px 0 0;
|
||||
}
|
||||
|
||||
.support-recipient-sheet {
|
||||
padding: 12px 20px calc(24px + var(--ion-safe-area-bottom));
|
||||
}
|
||||
|
||||
.support-recipient-sheet-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.support-recipient-sheet-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #171827;
|
||||
}
|
||||
|
||||
.support-recipient-sheet-subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: rgba(23, 24, 39, 0.56);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.support-recipient-sheet-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-recipient-sheet-close ion-icon {
|
||||
font-size: 20px;
|
||||
color: #171827;
|
||||
}
|
||||
|
||||
.support-recipient-sheet-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.support-recipient-sheet-item {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
background: #fafafa;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.support-recipient-sheet-item.selected {
|
||||
background: rgba(109, 40, 217, 0.08);
|
||||
}
|
||||
|
||||
.support-recipient-sheet-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.support-recipient-selected-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: #6d28d9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-explainer-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sfc-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-dark);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.support-context-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.support-context-row-amount {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.support-context-avatar-wrap {
|
||||
position: relative;
|
||||
width: 74px;
|
||||
height: 74px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-context-avatar {
|
||||
width: 67px;
|
||||
height: 67px;
|
||||
background: var(--flow-icon-soft, rgba(109, 40, 217, 0.12));
|
||||
}
|
||||
|
||||
.support-context-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.support-context-badge {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background: var(--flow-icon-soft, rgba(109, 40, 217, 0.12));
|
||||
color: var(--flow-accent, #6d28d9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 0 3px #ffffff;
|
||||
}
|
||||
|
||||
.support-context-badge ion-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.support-context-badge-image {
|
||||
width: 22.5px;
|
||||
height: 22.5px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.support-context-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: var(--flow-icon-soft, rgba(109, 40, 217, 0.12));
|
||||
color: var(--flow-accent, #6d28d9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-context-icon ion-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.support-context-image {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.support-step-kicker {
|
||||
margin: 0 0 4px;
|
||||
color: var(--flow-accent, #6d28d9);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.support-themed-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.support-recurring-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: var(--flow-accent-soft, rgba(109, 40, 217, 0.1));
|
||||
color: var(--flow-accent, #6d28d9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-recurring-icon ion-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.support-amount-hint {
|
||||
margin: 2px 0 0;
|
||||
color: rgba(23, 24, 39, 0.58);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Service Type Cards */
|
||||
.service-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.service-type-card {
|
||||
background: var(--service-card-bg, #fafafa);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.service-type-card.selected {
|
||||
background: var(--service-tint, rgba(109, 40, 217, 0.08));
|
||||
border-color: var(--service-accent, var(--ion-color-primary));
|
||||
}
|
||||
|
||||
.stc-icon-box {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stc-icon-box ion-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.service-type-card.service-medication .stc-icon-box ion-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.support-service-image {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.support-service-image-grocery {
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
}
|
||||
|
||||
.stc-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ion-color-dark);
|
||||
}
|
||||
|
||||
/* Amount Selection */
|
||||
.amount-preset-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.amount-chip {
|
||||
padding: 10px 20px;
|
||||
border-radius: 999px;
|
||||
background: #fafafa;
|
||||
color: var(--ion-color-dark);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.amount-chip.active {
|
||||
background: rgba(109, 40, 217, 0.1);
|
||||
border-color: var(--ion-color-primary);
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
|
||||
.support-form-card-themed .amount-chip.active {
|
||||
background: var(--flow-accent-soft, rgba(109, 40, 217, 0.1));
|
||||
border-color: var(--flow-accent, var(--ion-color-primary));
|
||||
color: var(--flow-accent, var(--ion-color-primary));
|
||||
}
|
||||
|
||||
.custom-amount-input {
|
||||
--background: #fafafa;
|
||||
--color: #171827;
|
||||
--placeholder-color: rgba(23, 24, 39, 0.42);
|
||||
--placeholder-opacity: 1;
|
||||
--border-radius: 12px;
|
||||
--padding-start: 16px;
|
||||
color: #171827;
|
||||
caret-color: var(--flow-accent, #6d28d9);
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.custom-amount-input input {
|
||||
color: #171827 !important;
|
||||
caret-color: var(--flow-accent, #6d28d9);
|
||||
}
|
||||
|
||||
/* Inline Action Card */
|
||||
.support-page-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-bottom: calc(20px + var(--ion-safe-area-bottom));
|
||||
}
|
||||
|
||||
.support-inline-action-card {
|
||||
margin: 0 20px calc(20px + var(--ion-safe-area-bottom));
|
||||
padding: 16px 18px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ssb-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.ssb-info-placeholder {
|
||||
opacity: 0.42;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ssb-label {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--ion-color-medium);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ssb-total {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-dark);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ssb-btn {
|
||||
--background: var(--flow-accent, var(--ion-color-primary));
|
||||
--background-activated: var(--flow-accent, var(--ion-color-primary));
|
||||
--border-radius: 999px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.support-payment-result-card {
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
margin: 16px 20px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sprc-icon-shell {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sprc-icon-shell.success {
|
||||
background: rgba(22, 163, 74, 0.12);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.sprc-icon-shell.failure {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.sprc-icon-shell ion-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.sprc-eyebrow {
|
||||
margin: 0;
|
||||
color: rgba(23, 24, 39, 0.48);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.sprc-title {
|
||||
margin: 0 0 6px;
|
||||
color: #171827;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.sprc-body {
|
||||
margin: 0;
|
||||
color: rgba(23, 24, 39, 0.62);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sprc-summary-slab {
|
||||
width: 100%;
|
||||
background: #fafafa;
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sprc-summary-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sprc-summary-row span {
|
||||
color: rgba(23, 24, 39, 0.56);
|
||||
}
|
||||
|
||||
.sprc-summary-row strong {
|
||||
color: #171827;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sprc-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.sprc-primary-btn {
|
||||
--background: var(--flow-accent, var(--ion-color-primary));
|
||||
--background-activated: var(--flow-accent, var(--ion-color-primary));
|
||||
--border-radius: 999px;
|
||||
margin: 0;
|
||||
min-height: 48px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sprc-secondary-btn {
|
||||
--background: #fafafa;
|
||||
--color: #171827;
|
||||
--border-radius: 999px;
|
||||
--box-shadow: none;
|
||||
margin: 0;
|
||||
min-height: 48px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -2,6 +2,6 @@ import { createClient } from '@supabase/supabase-js';
|
||||
import type { Database } from './database.types';
|
||||
|
||||
export const supabase = createClient<Database>(
|
||||
'https://pdtnuymihtxnhmzaybif.supabase.co',
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBkdG51eW1paHR4bmhtemF5YmlmIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODE5NTQwOTYsImV4cCI6MjA5NzUzMDA5Nn0.k-3YKruldYrh88JSt4E-Q8veUbnAE6hrsxlrWmwjzeM',
|
||||
'https://zillycfjfbzofltgyjwy.supabase.co',
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InppbGx5Y2ZqZmJ6b2ZsdGd5and5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkxMjYzODQsImV4cCI6MjA5NDcwMjM4NH0.BB4ccBEQI2krc7SseC73234Nfya-92VGcTELNQdzt50',
|
||||
);
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// Ambient test globals for optional *.test.* files in the editor / tsc.
|
||||
// No test runner is installed in the template; stubs avoid spurious TS errors.
|
||||
|
||||
interface Matchers<R = void> {
|
||||
toBeDefined(): R;
|
||||
toBe(expected: unknown): R;
|
||||
toEqual(expected: unknown): R;
|
||||
toBeTruthy(): R;
|
||||
toBeFalsy(): R;
|
||||
toBeNull(): R;
|
||||
toContain(item: unknown): R;
|
||||
toHaveLength(length: number): R;
|
||||
toMatch(expected: string | RegExp): R;
|
||||
toThrow(expected?: string | RegExp | Error): R;
|
||||
not: Matchers<R>;
|
||||
}
|
||||
|
||||
interface Expect {
|
||||
<T = unknown>(actual: T): Matchers;
|
||||
}
|
||||
|
||||
declare const expect: Expect;
|
||||
|
||||
type TestCaseFn = (
|
||||
name: string,
|
||||
fn?: () => void | Promise<void>,
|
||||
timeout?: number,
|
||||
) => void;
|
||||
|
||||
declare const test: TestCaseFn;
|
||||
declare const it: TestCaseFn;
|
||||
declare const describe: TestCaseFn;
|
||||
|
||||
declare function beforeEach(fn: () => void | Promise<void>): void;
|
||||
declare function afterEach(fn: () => void | Promise<void>): void;
|
||||
declare function beforeAll(fn: () => void | Promise<void>): void;
|
||||
declare function afterAll(fn: () => void | Promise<void>): void;
|
||||
|
||||
declare module "@testing-library/react" {
|
||||
export function render(
|
||||
ui: unknown,
|
||||
options?: object,
|
||||
): {
|
||||
baseElement: Element;
|
||||
container: Element;
|
||||
unmount(): void;
|
||||
rerender(ui: unknown): void;
|
||||
};
|
||||
export const screen: Record<string, (...args: unknown[]) => Element>;
|
||||
export function cleanup(): void;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export const formatMoney = (amount: number, currency = 'USD') => {
|
||||
const safeCurrency = currency || 'USD';
|
||||
const formatted = new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: safeCurrency,
|
||||
}).format(amount);
|
||||
|
||||
return safeCurrency.toUpperCase() === 'USD'
|
||||
? formatted.replace(/US\$/g, '$')
|
||||
: formatted;
|
||||
};
|
||||
@@ -1,615 +0,0 @@
|
||||
import { supabase } from '../supabase';
|
||||
import basketIcon from '../assets/basket.png';
|
||||
import {
|
||||
flashOutline,
|
||||
medkitOutline,
|
||||
phonePortraitOutline,
|
||||
timeOutline,
|
||||
} from 'ionicons/icons';
|
||||
|
||||
export type HomeAlert = {
|
||||
id: string;
|
||||
scheduleId: string;
|
||||
recipientId: string;
|
||||
serviceType: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: string;
|
||||
iconType?: 'ion' | 'image';
|
||||
tone: 'grocery' | 'medication' | 'airtime' | 'electricity' | 'default';
|
||||
cta: string;
|
||||
};
|
||||
|
||||
export type SupportCategoryTone =
|
||||
| 'grocery'
|
||||
| 'medication'
|
||||
| 'airtime'
|
||||
| 'electricity';
|
||||
|
||||
export type HomeSnapshot = {
|
||||
monthlySentTotal: number;
|
||||
alerts: HomeAlert[];
|
||||
lovedOnes: any[];
|
||||
activities: any[];
|
||||
imageUrls: string[];
|
||||
};
|
||||
|
||||
export const buildSnapshotHash = (snapshot: HomeSnapshot) =>
|
||||
JSON.stringify({
|
||||
monthlySentTotal: snapshot.monthlySentTotal,
|
||||
alerts: snapshot.alerts,
|
||||
lovedOnes: snapshot.lovedOnes,
|
||||
activities: snapshot.activities,
|
||||
});
|
||||
|
||||
export type CachedHomeSnapshot = HomeSnapshot & {
|
||||
userId: string;
|
||||
cachedAt: string;
|
||||
};
|
||||
|
||||
let warmedHomeSnapshot: CachedHomeSnapshot | null = null;
|
||||
|
||||
export const setWarmedHomeSnapshot = (
|
||||
userId: string,
|
||||
snapshot: HomeSnapshot
|
||||
): CachedHomeSnapshot => {
|
||||
warmedHomeSnapshot = {
|
||||
userId,
|
||||
monthlySentTotal: snapshot.monthlySentTotal,
|
||||
alerts: snapshot.alerts,
|
||||
lovedOnes: snapshot.lovedOnes,
|
||||
activities: snapshot.activities,
|
||||
imageUrls: snapshot.imageUrls,
|
||||
cachedAt: new Date().toISOString(),
|
||||
};
|
||||
return warmedHomeSnapshot;
|
||||
};
|
||||
|
||||
export const getWarmedHomeSnapshot = (userId: string) => {
|
||||
if (warmedHomeSnapshot?.userId !== userId) return null;
|
||||
return warmedHomeSnapshot;
|
||||
};
|
||||
|
||||
export const preloadImageUrls = async (urls: string[]) => {
|
||||
const uniqueUrls = Array.from(new Set(urls.filter(Boolean)));
|
||||
|
||||
await Promise.all(
|
||||
uniqueUrls.map(
|
||||
(url) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const image = new Image();
|
||||
image.decoding = 'sync';
|
||||
image.loading = 'eager';
|
||||
image.onload = () => {
|
||||
if (typeof image.decode === 'function') {
|
||||
image
|
||||
.decode()
|
||||
.catch(() => undefined)
|
||||
.finally(resolve);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
image.onerror = () => resolve();
|
||||
image.src = url;
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const formatRelativeSupportDate = (value?: string | null) => {
|
||||
if (!value) return 'No support yet';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return 'Recent support';
|
||||
const today = new Date();
|
||||
const startOfToday = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate()
|
||||
);
|
||||
const startOfDate = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate()
|
||||
);
|
||||
const diffDays = Math.floor(
|
||||
(startOfToday.getTime() - startOfDate.getTime()) / 86_400_000
|
||||
);
|
||||
|
||||
if (diffDays <= 0) return 'Last support: today';
|
||||
if (diffDays === 1) return 'Last support: yesterday';
|
||||
if (diffDays < 30) return `Last support: ${diffDays} days ago`;
|
||||
|
||||
return `Last support: ${date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}`;
|
||||
};
|
||||
|
||||
const formatAlertSubtitle = (firstName: string, dueAt?: string | null) => {
|
||||
if (!dueAt) return `For ${firstName}`;
|
||||
const date = new Date(dueAt);
|
||||
if (Number.isNaN(date.getTime())) return `For ${firstName}`;
|
||||
const today = new Date();
|
||||
const startOfToday = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate()
|
||||
);
|
||||
const startOfDate = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate()
|
||||
);
|
||||
const diffDays = Math.round(
|
||||
(startOfDate.getTime() - startOfToday.getTime()) / 86_400_000
|
||||
);
|
||||
|
||||
if (diffDays < 0) return `For ${firstName} · overdue`;
|
||||
if (diffDays === 0) return `For ${firstName} · today`;
|
||||
if (diffDays === 1) return `For ${firstName} · tomorrow`;
|
||||
return `For ${firstName} · in ${diffDays} days`;
|
||||
};
|
||||
|
||||
const getNormalizedServiceType = (value: string) => {
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized.includes('grocery')) return 'grocery';
|
||||
if (normalized.includes('medication') || normalized.includes('pharmacy')) {
|
||||
return 'medication';
|
||||
}
|
||||
if (normalized.includes('airtime') || normalized.includes('data')) {
|
||||
return 'airtime';
|
||||
}
|
||||
if (normalized.includes('electricity') || normalized.includes('zesa')) {
|
||||
return 'electricity';
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const getAlertPresentation = (serviceType: string) => {
|
||||
switch (serviceType) {
|
||||
case 'grocery':
|
||||
return {
|
||||
title: 'Grocery',
|
||||
icon: basketIcon,
|
||||
tone: 'grocery' as const,
|
||||
cta: 'Send support',
|
||||
};
|
||||
case 'medication':
|
||||
return {
|
||||
title: 'Medication',
|
||||
icon: medkitOutline,
|
||||
tone: 'medication' as const,
|
||||
cta: 'Send support',
|
||||
};
|
||||
case 'airtime':
|
||||
return {
|
||||
title: 'Airtime',
|
||||
icon: phonePortraitOutline,
|
||||
tone: 'airtime' as const,
|
||||
cta: 'Top up',
|
||||
};
|
||||
case 'electricity':
|
||||
return {
|
||||
title: 'Electricity',
|
||||
icon: flashOutline,
|
||||
tone: 'electricity' as const,
|
||||
cta: 'Top up',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: 'Support',
|
||||
icon: timeOutline,
|
||||
tone: 'default' as const,
|
||||
cta: 'Send support',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getActivityStatusTone = (serviceType: string, status: string) => {
|
||||
const normalizedStatus = status.toLowerCase();
|
||||
const normalizedService = serviceType.toLowerCase();
|
||||
const isVoucherService =
|
||||
normalizedService === 'grocery' || normalizedService === 'medication';
|
||||
|
||||
if (isVoucherService) {
|
||||
if (normalizedStatus.includes('redeemed')) return 'success';
|
||||
if (normalizedStatus.includes('partial')) return 'partial';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('completed') ||
|
||||
normalizedStatus.includes('active')
|
||||
) {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('delivered') ||
|
||||
normalizedStatus.includes('created') ||
|
||||
normalizedStatus === 'ready_for_redemption'
|
||||
) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const getActivityStatusLabel = (serviceType: string, status: string) => {
|
||||
const normalizedService = serviceType.toLowerCase();
|
||||
const normalizedStatus = status.toLowerCase();
|
||||
const isVoucherService =
|
||||
normalizedService === 'grocery' || normalizedService === 'medication';
|
||||
|
||||
if (isVoucherService) {
|
||||
if (normalizedStatus.includes('redeemed')) return 'redeemed';
|
||||
if (normalizedStatus.includes('partial')) return 'partial';
|
||||
return 'created';
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'ready_for_redemption') return 'created';
|
||||
|
||||
return normalizedStatus.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
export const fetchHomeSnapshot = async (
|
||||
activeUserId: string,
|
||||
getRecipientAvatarUrl: (
|
||||
photoPath?: string | null,
|
||||
firstName?: string | null
|
||||
) => Promise<string | null>
|
||||
): Promise<HomeSnapshot> => {
|
||||
const now = new Date();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const nextMonthStart = new Date(now.getFullYear(), now.getMonth() + 1, 1)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
let monthlySentTotal = 0;
|
||||
let alerts: HomeAlert[] = [];
|
||||
let lovedOnes: any[] = [];
|
||||
let activities: any[] = [];
|
||||
const imageUrls = new Set<string>();
|
||||
|
||||
const { data: monthlyOrdersData } = await supabase
|
||||
.from('support_orders')
|
||||
.select('amount, created_at')
|
||||
.eq('user_id', activeUserId)
|
||||
.gte('created_at', monthStart)
|
||||
.lt('created_at', nextMonthStart);
|
||||
|
||||
if (monthlyOrdersData) {
|
||||
monthlySentTotal = monthlyOrdersData.reduce(
|
||||
(sum, order) => sum + Number(order.amount || 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
const { data: alertsData } = await supabase
|
||||
.from('care_alerts')
|
||||
.select('id, title, body, severity, service_type, due_at, recipient_id')
|
||||
.eq('user_id', activeUserId)
|
||||
.is('dismissed_at', null)
|
||||
.order('due_at', { ascending: true, nullsFirst: false })
|
||||
.limit(5);
|
||||
|
||||
if (alertsData) {
|
||||
const recipientIds = Array.from(
|
||||
new Set(alertsData.map((alert) => alert.recipient_id).filter(Boolean))
|
||||
);
|
||||
const recipientNameById = new Map<string, string>();
|
||||
|
||||
if (recipientIds.length > 0) {
|
||||
const { data: alertRecipients } = await supabase
|
||||
.from('recipients')
|
||||
.select('id, first_name')
|
||||
.in('id', recipientIds);
|
||||
|
||||
(alertRecipients || []).forEach((recipient) => {
|
||||
recipientNameById.set(recipient.id, recipient.first_name);
|
||||
});
|
||||
}
|
||||
|
||||
alerts = alertsData.map((alert) => {
|
||||
const presentation = getAlertPresentation(
|
||||
alert.service_type || 'default'
|
||||
);
|
||||
const firstName =
|
||||
recipientNameById.get(alert.recipient_id) || 'Loved one';
|
||||
return {
|
||||
id: alert.id,
|
||||
scheduleId: alert.id,
|
||||
recipientId: alert.recipient_id,
|
||||
serviceType: alert.service_type || 'support',
|
||||
title: presentation.title,
|
||||
subtitle: formatAlertSubtitle(firstName, alert.due_at),
|
||||
icon: presentation.icon,
|
||||
iconType: presentation.icon === basketIcon ? 'image' : 'ion',
|
||||
tone: presentation.tone,
|
||||
cta: presentation.cta,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const { data: recipientsData } = await supabase
|
||||
.from('recipients')
|
||||
.select(
|
||||
'id, first_name, last_name, country, city, photo_path, relationship, pinned_at, archived_at'
|
||||
)
|
||||
.eq('user_id', activeUserId)
|
||||
.is('archived_at', null);
|
||||
|
||||
if (recipientsData) {
|
||||
const recipientIds = recipientsData.map((recipient) => recipient.id);
|
||||
const { data: allRecipientOrders } = await supabase
|
||||
.from('support_orders')
|
||||
.select(
|
||||
'id, service_type, status, created_at, recipient_id, merchants(name)'
|
||||
)
|
||||
.eq('user_id', activeUserId)
|
||||
.in('recipient_id', recipientIds)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
const ordersByRecipient = new Map<string, any[]>();
|
||||
(allRecipientOrders || []).forEach((order: any) => {
|
||||
const current = ordersByRecipient.get(order.recipient_id) || [];
|
||||
current.push(order);
|
||||
ordersByRecipient.set(order.recipient_id, current);
|
||||
});
|
||||
|
||||
const recipientsWithHistory = recipientsData.filter(
|
||||
(recipient) => (ordersByRecipient.get(recipient.id) || []).length > 0
|
||||
);
|
||||
|
||||
const mappedLovedOnes = await Promise.all(
|
||||
recipientsWithHistory.map(async (rec: any) => {
|
||||
const avatarUrl = await getRecipientAvatarUrl(
|
||||
rec.photo_path,
|
||||
rec.first_name
|
||||
);
|
||||
if (avatarUrl) {
|
||||
imageUrls.add(avatarUrl);
|
||||
}
|
||||
|
||||
const recentOrders = (ordersByRecipient.get(rec.id) || []).slice(0, 20);
|
||||
const orderedStatuses = recentOrders.slice(0, 3);
|
||||
const primaryOrder = recentOrders[0];
|
||||
|
||||
const statuses = orderedStatuses.map((order: any) => {
|
||||
const tone = getAlertPresentation(order.service_type).tone;
|
||||
const isSuccess =
|
||||
order.status === 'redeemed' || order.status === 'completed';
|
||||
|
||||
let detail = getActivityStatusLabel(order.service_type, order.status);
|
||||
if (order.status === 'redeemed' && order.merchants?.name) {
|
||||
detail = `redeemed • ${order.merchants.name}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: order.id,
|
||||
iconType: tone === 'grocery' ? 'image' : 'ion',
|
||||
iconSrc: tone === 'grocery' ? basketIcon : undefined,
|
||||
icon:
|
||||
tone !== 'grocery'
|
||||
? getAlertPresentation(order.service_type).icon
|
||||
: undefined,
|
||||
iconAlt: order.service_type,
|
||||
label:
|
||||
order.service_type === 'grocery'
|
||||
? 'Grocery voucher'
|
||||
: order.service_type === 'medication'
|
||||
? 'Medication voucher'
|
||||
: order.service_type === 'electricity'
|
||||
? 'Electricity'
|
||||
: order.service_type === 'airtime'
|
||||
? 'Airtime'
|
||||
: `${order.service_type.charAt(0).toUpperCase()}${order.service_type.slice(1)}`,
|
||||
detail,
|
||||
status: isSuccess ? 'success' : 'warning',
|
||||
iconTone: tone,
|
||||
serviceType: order.service_type,
|
||||
createdAt: order.created_at,
|
||||
};
|
||||
});
|
||||
|
||||
const primaryTone = getAlertPresentation(
|
||||
primaryOrder.service_type
|
||||
).tone;
|
||||
const repeatCta =
|
||||
primaryOrder.service_type === 'medication'
|
||||
? 'Send Medication'
|
||||
: primaryOrder.service_type === 'airtime'
|
||||
? 'Top Up Again'
|
||||
: primaryOrder.service_type === 'electricity'
|
||||
? 'Send Electricity'
|
||||
: 'Send Groceries';
|
||||
|
||||
return {
|
||||
id: rec.id,
|
||||
pinnedAt: rec.pinned_at,
|
||||
name: rec.first_name,
|
||||
emoji:
|
||||
rec.relationship === 'Mother' || rec.first_name === 'Mum'
|
||||
? '💜'
|
||||
: '💚',
|
||||
location: `${rec.city || ''}, ${rec.country || ''}`.replace(
|
||||
/^, | , $/g,
|
||||
''
|
||||
),
|
||||
cardTone: rec.first_name === 'Mum' ? 'lavender' : 'mint',
|
||||
avatar: avatarUrl,
|
||||
fallbackInitial: rec.first_name?.charAt(0)?.toUpperCase() ?? '?',
|
||||
lastSupportLabel: formatRelativeSupportDate(primaryOrder.created_at),
|
||||
lastSupportedAt: primaryOrder.created_at,
|
||||
repeatCta,
|
||||
repeatAction: {
|
||||
iconType: primaryTone === 'grocery' ? 'image' : 'ion',
|
||||
iconSrc: primaryTone === 'grocery' ? basketIcon : undefined,
|
||||
icon:
|
||||
primaryTone !== 'grocery'
|
||||
? getAlertPresentation(primaryOrder.service_type).icon
|
||||
: undefined,
|
||||
tone: primaryTone as SupportCategoryTone,
|
||||
},
|
||||
statuses,
|
||||
primaryServiceType: primaryOrder.service_type,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
lovedOnes = mappedLovedOnes.sort((a, b) => {
|
||||
if (a.pinnedAt && !b.pinnedAt) return -1;
|
||||
if (!a.pinnedAt && b.pinnedAt) return 1;
|
||||
return (
|
||||
new Date(b.lastSupportedAt).getTime() -
|
||||
new Date(a.lastSupportedAt).getTime()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const { data: recentActivityData } = await supabase
|
||||
.from('support_orders')
|
||||
.select(
|
||||
'id, service_type, status, amount, created_at, recipient_id, merchant_id'
|
||||
)
|
||||
.eq('user_id', activeUserId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5);
|
||||
|
||||
if (recentActivityData) {
|
||||
const activityRecipientIds = Array.from(
|
||||
new Set(
|
||||
recentActivityData
|
||||
.map((activity) => activity.recipient_id)
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
const activityMerchantIds = Array.from(
|
||||
new Set(
|
||||
recentActivityData
|
||||
.map((activity) => activity.merchant_id)
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
const recipientsById = new Map<
|
||||
string,
|
||||
{ first_name: string; photo_path: string | null }
|
||||
>();
|
||||
const merchantsById = new Map<string, string>();
|
||||
|
||||
if (activityRecipientIds.length > 0) {
|
||||
const { data: activityRecipients } = await supabase
|
||||
.from('recipients')
|
||||
.select('id, first_name, photo_path')
|
||||
.in('id', activityRecipientIds);
|
||||
|
||||
(activityRecipients || []).forEach((recipient) => {
|
||||
recipientsById.set(recipient.id, {
|
||||
first_name: recipient.first_name,
|
||||
photo_path: recipient.photo_path,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (activityMerchantIds.length > 0) {
|
||||
const { data: activityMerchants } = await supabase
|
||||
.from('merchants')
|
||||
.select('id, name')
|
||||
.in('id', activityMerchantIds);
|
||||
|
||||
(activityMerchants || []).forEach((merchant) => {
|
||||
merchantsById.set(merchant.id, merchant.name);
|
||||
});
|
||||
}
|
||||
|
||||
const mappedActivity = await Promise.all(
|
||||
recentActivityData.map(async (act) => {
|
||||
const recipient = recipientsById.get(act.recipient_id);
|
||||
const avatarUrl = await getRecipientAvatarUrl(
|
||||
recipient?.photo_path,
|
||||
recipient?.first_name
|
||||
);
|
||||
if (avatarUrl) {
|
||||
imageUrls.add(avatarUrl);
|
||||
}
|
||||
const tone = getAlertPresentation(act.service_type).tone;
|
||||
|
||||
const activityTitle =
|
||||
act.service_type === 'grocery'
|
||||
? 'Grocery voucher'
|
||||
: act.service_type === 'medication'
|
||||
? 'Medication voucher'
|
||||
: act.service_type === 'airtime'
|
||||
? 'Airtime'
|
||||
: act.service_type === 'data'
|
||||
? 'Data'
|
||||
: act.service_type === 'electricity'
|
||||
? 'Electricity'
|
||||
: `${act.service_type.charAt(0).toUpperCase()}${act.service_type.slice(1)}`;
|
||||
|
||||
const normalizedServiceType = getNormalizedServiceType(
|
||||
act.service_type
|
||||
);
|
||||
const statusLabel = getActivityStatusLabel(
|
||||
act.service_type,
|
||||
act.status
|
||||
);
|
||||
const merchantName =
|
||||
(act.merchant_id && merchantsById.get(act.merchant_id)) || null;
|
||||
const isVoucherSupport =
|
||||
normalizedServiceType === 'grocery' ||
|
||||
normalizedServiceType === 'medication';
|
||||
|
||||
let subtitle = merchantName;
|
||||
if (!subtitle) {
|
||||
if (isVoucherSupport) {
|
||||
subtitle =
|
||||
statusLabel === 'redeemed'
|
||||
? 'Voucher redeemed'
|
||||
: 'Voucher ready for collection';
|
||||
} else if (normalizedServiceType === 'electricity') {
|
||||
subtitle = 'Meter support sent';
|
||||
} else if (normalizedServiceType === 'airtime') {
|
||||
subtitle = 'Top-up sent';
|
||||
} else {
|
||||
subtitle = 'Support sent';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: act.id,
|
||||
recipientId: act.recipient_id,
|
||||
title: activityTitle,
|
||||
subtitle,
|
||||
amount: `${Number(act.amount).toFixed(2)}`,
|
||||
status: statusLabel,
|
||||
tone: getActivityStatusTone(act.service_type, act.status),
|
||||
icon: {
|
||||
iconType: tone === 'grocery' ? 'image' : 'ion',
|
||||
iconSrc: tone === 'grocery' ? basketIcon : undefined,
|
||||
icon:
|
||||
tone !== 'grocery'
|
||||
? getAlertPresentation(act.service_type).icon
|
||||
: undefined,
|
||||
},
|
||||
iconTone: tone,
|
||||
avatar: avatarUrl,
|
||||
fallbackInitial:
|
||||
recipient?.first_name?.charAt(0)?.toUpperCase() ?? '?',
|
||||
};
|
||||
})
|
||||
);
|
||||
activities = mappedActivity;
|
||||
}
|
||||
|
||||
return {
|
||||
monthlySentTotal,
|
||||
alerts,
|
||||
lovedOnes,
|
||||
activities,
|
||||
imageUrls: Array.from(imageUrls),
|
||||
};
|
||||
};
|
||||
@@ -1,129 +0,0 @@
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import type { HomeSnapshot } from './homeSnapshot';
|
||||
|
||||
const MAX_CACHEABLE_IMAGE_BYTES = 900_000;
|
||||
|
||||
const memoryImageCache = new Map<string, string>();
|
||||
|
||||
const hashString = (value: string) => {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(index);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
};
|
||||
|
||||
const getImageCacheKey = (userId: string, url: string) =>
|
||||
`cache_${userId}_image_${hashString(url)}`;
|
||||
|
||||
const readImageCache = async (key: string) => {
|
||||
const memoryValue = memoryImageCache.get(key);
|
||||
if (memoryValue) return memoryValue;
|
||||
|
||||
const { value } = await Preferences.get({ key });
|
||||
if (value) {
|
||||
memoryImageCache.set(key, value);
|
||||
}
|
||||
return value || null;
|
||||
};
|
||||
|
||||
const writeImageCache = async (key: string, value: string) => {
|
||||
memoryImageCache.set(key, value);
|
||||
await Preferences.set({ key, value });
|
||||
};
|
||||
|
||||
const blobToDataUrl = (blob: Blob) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
const decodeImageUrl = (url: string) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const image = new Image();
|
||||
image.decoding = 'sync';
|
||||
image.onload = () => {
|
||||
if (typeof image.decode === 'function') {
|
||||
image
|
||||
.decode()
|
||||
.catch(() => undefined)
|
||||
.finally(resolve);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
image.onerror = () => resolve();
|
||||
image.src = url;
|
||||
});
|
||||
|
||||
export const cacheImageUrl = async (
|
||||
userId: string,
|
||||
url?: string | null
|
||||
): Promise<string | null> => {
|
||||
if (!url) return null;
|
||||
if (url.startsWith('data:')) {
|
||||
await decodeImageUrl(url);
|
||||
return url;
|
||||
}
|
||||
|
||||
const cacheKey = getImageCacheKey(userId, url);
|
||||
|
||||
try {
|
||||
const cached = await readImageCache(cacheKey);
|
||||
if (cached) {
|
||||
await decodeImageUrl(cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const response = await fetch(url, { cache: 'force-cache' });
|
||||
if (!response.ok) return url;
|
||||
|
||||
const blob = await response.blob();
|
||||
if (!blob.type.startsWith('image/')) return url;
|
||||
if (blob.size > MAX_CACHEABLE_IMAGE_BYTES) return url;
|
||||
|
||||
const dataUrl = await blobToDataUrl(blob);
|
||||
await writeImageCache(cacheKey, dataUrl);
|
||||
await decodeImageUrl(dataUrl);
|
||||
return dataUrl;
|
||||
} catch (error) {
|
||||
console.warn('[image cache] falling back to network image', error);
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
export const hydrateHomeSnapshotImages = async (
|
||||
userId: string,
|
||||
snapshot: HomeSnapshot
|
||||
): Promise<HomeSnapshot> => {
|
||||
const urls = Array.from(
|
||||
new Set([
|
||||
...snapshot.imageUrls,
|
||||
...snapshot.lovedOnes.map((person) => person.avatar).filter(Boolean),
|
||||
...snapshot.activities.map((activity) => activity.avatar).filter(Boolean),
|
||||
] as string[])
|
||||
);
|
||||
|
||||
const entries = await Promise.all(
|
||||
urls.map(async (url) => [url, await cacheImageUrl(userId, url)] as const)
|
||||
);
|
||||
const cachedUrlByOriginal = new Map(entries);
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
lovedOnes: snapshot.lovedOnes.map((person) => ({
|
||||
...person,
|
||||
avatar: cachedUrlByOriginal.get(person.avatar) || person.avatar,
|
||||
})),
|
||||
activities: snapshot.activities.map((activity) => ({
|
||||
...activity,
|
||||
avatar: cachedUrlByOriginal.get(activity.avatar) || activity.avatar,
|
||||
})),
|
||||
imageUrls: snapshot.imageUrls
|
||||
.map((url) => cachedUrlByOriginal.get(url) || url)
|
||||
.filter(Boolean),
|
||||
};
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
|
||||
const memoryCache = new Map<string, string>();
|
||||
|
||||
export const buildCacheKey = (userId: string, key: string): string => {
|
||||
return `cache_${userId}_${key}`;
|
||||
};
|
||||
|
||||
export const writeCache = async (key: string, data: any): Promise<void> => {
|
||||
try {
|
||||
const value = JSON.stringify(data);
|
||||
memoryCache.set(key, value);
|
||||
await Preferences.set({ key, value });
|
||||
} catch (error) {
|
||||
console.error(`[Cache] Failed to write cache for key: ${key}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const readCache = async <T,>(key: string): Promise<T | null> => {
|
||||
try {
|
||||
const memoryValue = memoryCache.get(key);
|
||||
if (memoryValue) return JSON.parse(memoryValue) as T;
|
||||
|
||||
const { value } = await Preferences.get({ key });
|
||||
if (!value) return null;
|
||||
memoryCache.set(key, value);
|
||||
return JSON.parse(value) as T;
|
||||
} catch (error) {
|
||||
console.error(`[Cache] Failed to read cache for key: ${key}`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const removeCache = async (key: string): Promise<void> => {
|
||||
try {
|
||||
memoryCache.delete(key);
|
||||
await Preferences.remove({ key });
|
||||
} catch (error) {
|
||||
console.error(`[Cache] Failed to remove cache for key: ${key}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const clearUserCache = async (userId: string): Promise<void> => {
|
||||
try {
|
||||
const userPrefix = `cache_${userId}_`;
|
||||
Array.from(memoryCache.keys()).forEach((key) => {
|
||||
if (key.startsWith(userPrefix)) memoryCache.delete(key);
|
||||
});
|
||||
|
||||
const { keys } = await Preferences.keys();
|
||||
const userKeys = keys.filter((k) => k.startsWith(userPrefix));
|
||||
for (const key of userKeys) {
|
||||
await Preferences.remove({ key });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Cache] Failed to clear user cache for user: ${userId}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -2,17 +2,13 @@ import { StatusBar, Style } from '@capacitor/status-bar';
|
||||
export { Style };
|
||||
|
||||
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;
|
||||
})();
|
||||
|
||||
/** Set status bar icon/text colour. Use instead of StatusBar.setStyle directly. */
|
||||
export async function setStatusBarStyle(style: Style): Promise<void> {
|
||||
try {
|
||||
await StatusBar.setStyle({ style });
|
||||
} catch {}
|
||||
try { await StatusBar.setStyle({ style }); } catch {}
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage({ type: '__apsuite_statusbar', style }, _origin);
|
||||
}
|
||||
@@ -20,13 +16,8 @@ export async function setStatusBarStyle(style: Style): Promise<void> {
|
||||
|
||||
/** Set status bar background colour (Android). Use instead of StatusBar.setBackgroundColor directly. */
|
||||
export async function setStatusBarBackground(color: string): Promise<void> {
|
||||
try {
|
||||
await (StatusBar as any).setBackgroundColor({ color });
|
||||
} catch {}
|
||||
try { await (StatusBar as any).setBackgroundColor({ color }); } catch {}
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage(
|
||||
{ type: '__apsuite_statusbar_bg', color },
|
||||
_origin
|
||||
);
|
||||
window.parent.postMessage({ type: '__apsuite_statusbar_bg', color }, _origin);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers':
|
||||
'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||
const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')!;
|
||||
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: 'Missing auth headers' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
|
||||
const body = await req.json();
|
||||
const { notificationId } = body;
|
||||
|
||||
if (!notificationId) {
|
||||
return new Response(JSON.stringify({ error: 'Missing notificationId' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Auth validation check: ensure user token resolves
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const readAt = new Date().toISOString();
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('notifications')
|
||||
.update({ read_at: readAt })
|
||||
.eq('id', notificationId)
|
||||
.eq('user_id', user.id); // RLS also enforces this, but explicit is good
|
||||
|
||||
if (updateError) {
|
||||
return new Response(JSON.stringify({ error: updateError.message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, readAt }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2';
|
||||
import { cert, getApps, initializeApp } from 'npm:firebase-admin@12.2.0/app';
|
||||
import { getMessaging } from 'npm:firebase-admin@12.2.0/messaging';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers':
|
||||
'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
// Singleton — Edge Functions can be warm-reused; avoid "app already exists" error
|
||||
const app =
|
||||
getApps()[0] ??
|
||||
initializeApp({
|
||||
credential: cert(
|
||||
JSON.parse(
|
||||
atob(Deno.env.get('FIREBASE_SERVICE_ACCOUNT')!.replace(/\s/g, ''))
|
||||
)
|
||||
),
|
||||
});
|
||||
const messaging = getMessaging(app);
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||
const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')!;
|
||||
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: 'Missing auth headers' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
|
||||
const body = await req.json();
|
||||
const {
|
||||
userId,
|
||||
title,
|
||||
body: messageBody,
|
||||
type,
|
||||
orderId,
|
||||
voucherId,
|
||||
priority = 'important',
|
||||
persist = true,
|
||||
delaySeconds,
|
||||
} = body;
|
||||
|
||||
if (!userId || !title || !messageBody || !type) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Missing required parameters' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (delaySeconds && delaySeconds > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delaySeconds * 1000));
|
||||
}
|
||||
|
||||
// Persist notification if needed (using service role key to insert securely if needed, but here auth is user)
|
||||
// Wait, if an admin/webhook is calling this, they will use service_role. If a user is calling, they use their token.
|
||||
// Let's create a service-role client just for DB updates to ensure it works even if sender is different from recipient
|
||||
const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const adminSupabase = createClient(supabaseUrl, serviceRoleKey);
|
||||
|
||||
let notificationId: string | null = null;
|
||||
let persisted = false;
|
||||
|
||||
if (persist) {
|
||||
const { data: notifData, error: notifError } = await adminSupabase
|
||||
.from('notifications')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
title,
|
||||
body: messageBody,
|
||||
type,
|
||||
priority,
|
||||
order_id: orderId || null,
|
||||
voucher_id: voucherId || null,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (!notifError && notifData) {
|
||||
notificationId = notifData.id;
|
||||
persisted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Get user's profile to find fcm_token and preferences
|
||||
const { data: profile } = await adminSupabase
|
||||
.from('profiles')
|
||||
.select('fcm_token, notification_push_enabled')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
let sent = false;
|
||||
|
||||
if (profile && profile.notification_push_enabled && profile.fcm_token) {
|
||||
const message = {
|
||||
notification: {
|
||||
title: title,
|
||||
body: messageBody,
|
||||
},
|
||||
data: {
|
||||
orderId: orderId || '',
|
||||
voucherId: voucherId || '',
|
||||
type: type || '',
|
||||
},
|
||||
apns: {
|
||||
payload: {
|
||||
aps: {
|
||||
badge: 1,
|
||||
sound: 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
android: {
|
||||
notification: {
|
||||
sound: 'default',
|
||||
notificationCount: 1,
|
||||
},
|
||||
},
|
||||
token: profile.fcm_token,
|
||||
};
|
||||
|
||||
try {
|
||||
await messaging.send(message);
|
||||
sent = true;
|
||||
} catch (err: any) {
|
||||
if (
|
||||
err.code === 'messaging/registration-token-not-registered' ||
|
||||
err.code === 'messaging/invalid-registration-token'
|
||||
) {
|
||||
// Token is stale — remove it from the DB
|
||||
await adminSupabase
|
||||
.from('profiles')
|
||||
.update({ fcm_token: null })
|
||||
.eq('id', userId);
|
||||
}
|
||||
console.error('FCM send error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, sent, persisted, notificationId }),
|
||||
{
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": false,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
|
||||
@@ -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 react from '@vitejs/plugin-react';
|
||||
|
||||
@@ -8,27 +5,16 @@ const injectRouterBasename: Plugin = {
|
||||
name: 'inject-router-basename',
|
||||
enforce: 'pre',
|
||||
transform(code, id) {
|
||||
if (!/\.[jt]sx$/.test(id)) return null;
|
||||
const next = code.replace(
|
||||
if (!/\.[jt]sx$/.test(id)) return;
|
||||
return code.replace(
|
||||
/<IonReactRouter(?![^>]*basename)([^>]*)>/g,
|
||||
'<IonReactRouter$1 basename={import.meta.env.BASE_URL}>',
|
||||
);
|
||||
return next === code ? null : next;
|
||||
},
|
||||
};
|
||||
|
||||
const hideScrollbars: Plugin = {
|
||||
name: 'hide-scrollbars',
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(
|
||||
'</head>',
|
||||
'<style>*{scrollbar-width:none!important;-ms-overflow-style:none!important}*::-webkit-scrollbar{display:none!important}</style></head>',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [injectRouterBasename, hideScrollbars, react()],
|
||||
plugins: [injectRouterBasename, react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 8100,
|
||||
|
||||