Compare commits

..

1 Commits

Author SHA1 Message Date
AppCakes 98554b0086 build: 444813b5-5daf-49b2-bf1a-7e4caeab4e17 2026-07-04 11:56:40 +00:00
95 changed files with 23940 additions and 8620 deletions
Vendored
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@ android {
namespace = "io.ionic.starter" namespace = "io.ionic.starter"
compileSdk = rootProject.ext.compileSdkVersion compileSdk = rootProject.ext.compileSdkVersion
defaultConfig { defaultConfig {
applicationId "com.habitmode.llemba.com" applicationId "com.kumusha.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
+6 -1
View File
@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -22,6 +21,12 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.kumusha.app" />
</intent-filter>
</activity> </activity>
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<resources> <resources>
<string name="app_name">app</string> <string name="app_name">kumusha</string>
<string name="title_activity_main">app</string> <string name="title_activity_main">kumusha</string>
<string name="package_name">io.ionic.starter</string> <string name="package_name">io.ionic.starter</string>
<string name="custom_url_scheme">io.ionic.starter</string> <string name="custom_url_scheme">io.ionic.starter</string>
</resources> </resources>
-1
View File
@@ -15,5 +15,4 @@ ext {
cordovaAndroidVersion = '14.0.1' cordovaAndroidVersion = '14.0.1'
rgcfaIncludeGoogle = true rgcfaIncludeGoogle = true
androidxCredentialsVersion = '1.3.0' androidxCredentialsVersion = '1.3.0'
javaVersion = JavaVersion.VERSION_17
} }
+6 -3
View File
@@ -1,13 +1,16 @@
import type { CapacitorConfig } from '@capacitor/cli'; import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = { const config: CapacitorConfig = {
appId: 'com.habitmode.llemba.com', appId: 'com.kumusha.app',
appName: 'HabitMode', appName: 'kumusha',
webDir: 'dist', webDir: 'dist',
plugins: { plugins: {
FirebaseAuthentication: { FirebaseAuthentication: {
skipNativeAuth: false, skipNativeAuth: false,
providers: ["google.com", "apple.com"], providers: [],
},
PushNotifications: {
presentationOptions: ['badge', 'sound', 'banner'],
}, },
}, },
}; };
-22
View File
@@ -55,13 +55,6 @@ workflows:
if [ -n "$CM_DEBUG_KEYSTORE" ]; then if [ -n "$CM_DEBUG_KEYSTORE" ]; then
echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12 echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12
fi fi
- name: Check native credentials
script: |
if [ -n "$VITE_GOOGLE_MAPS_KEY" ]; then
echo "VITE_GOOGLE_MAPS_KEY: set (length=${#VITE_GOOGLE_MAPS_KEY}, ends …${VITE_GOOGLE_MAPS_KEY: -4})"
else
echo "WARNING: VITE_GOOGLE_MAPS_KEY not set — Google Maps will not render"
fi
- name: Build debug APK - name: Build debug APK
script: | script: |
cd android cd android
@@ -75,21 +68,6 @@ workflows:
else else
./gradlew assembleDebug --no-daemon ./gradlew assembleDebug --no-daemon
fi fi
- name: Verify manifest substitution
script: |
MANIFEST="android/app/build/intermediates/merged_manifests/debug/AndroidManifest.xml"
if [ -f "$MANIFEST" ]; then
VAL=$(grep -A1 'geo.API_KEY' "$MANIFEST" | grep -o 'android:value="[^"]*"' | cut -d'"' -f2)
if [ -z "$VAL" ]; then
echo "WARNING: geo.API_KEY meta-data not found in merged manifest"
elif [ "$VAL" = '${googleMapsApiKey}' ]; then
echo "WARNING: geo.API_KEY placeholder was not substituted — key missing from build env"
else
echo "geo.API_KEY substituted OK (ends …${VAL: -4})"
fi
else
echo "Merged manifest not found at $MANIFEST"
fi
artifacts: artifacts:
- android/app/build/outputs/apk/debug/app-debug.apk - android/app/build/outputs/apk/debug/app-debug.apk
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -308,7 +308,7 @@
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com; PRODUCT_BUNDLE_IDENTIFIER = com.kumusha.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -330,7 +330,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com; PRODUCT_BUNDLE_IDENTIFIER = com.kumusha.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
-6
View File
@@ -2,11 +2,5 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>aps-environment</key>
<string>production</string>
</dict> </dict>
</plist> </plist>
+23 -5
View File
@@ -13,21 +13,38 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return true return true
} }
func applicationWillResignActive(_ application: UIApplication) {} func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}
func applicationDidEnterBackground(_ application: UIApplication) {} func applicationDidEnterBackground(_ application: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}
func applicationWillEnterForeground(_ application: UIApplication) {} func applicationWillEnterForeground(_ application: UIApplication) {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
}
func applicationDidBecomeActive(_ application: UIApplication) {} func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}
func applicationWillTerminate(_ application: UIApplication) {} func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(app, open: url, options: options) return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
} }
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
// Called when the app was launched with an activity, including Universal Links.
// Feel free to add additional processing here, but if you want the App API to support
// tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
} }
@@ -45,4 +62,5 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error) NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
} }
} }
+8 -2
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>HabitMode</string> <string>kumusha</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -47,12 +47,18 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true/> <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> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
<key>CFBundleURLName</key>
<string>com.kumusha.app</string>
<key>CFBundleURLSchemes</key> <key>CFBundleURLSchemes</key>
<array> <array>
<string>com.googleusercontent.apps.1097913965629-pf48952i0uv9shgvie3dq11s18sd0t2p</string> <string>com.kumusha.app</string>
</array> </array>
</dict> </dict>
</array> </array>
-3404
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -14,19 +14,24 @@
"@capacitor/android": "8.3.4", "@capacitor/android": "8.3.4",
"@capacitor/app": "8.1.0", "@capacitor/app": "8.1.0",
"@capacitor/core": "8.3.4", "@capacitor/core": "8.3.4",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/google-maps": "^8.0.0", "@capacitor/google-maps": "^8.0.0",
"@capacitor/haptics": "8.0.2", "@capacitor/haptics": "8.0.2",
"@capacitor/ios": "8.3.4", "@capacitor/ios": "8.3.4",
"@capacitor/keyboard": "8.0.3", "@capacitor/keyboard": "8.0.3",
"@capacitor/preferences": "^8.0.1",
"@capacitor/push-notifications": "^8.1.1", "@capacitor/push-notifications": "^8.1.1",
"@capacitor/share": "^8.0.1",
"@capacitor/status-bar": "8.0.2", "@capacitor/status-bar": "8.0.2",
"@ionic/pwa-elements": "^3.4.0", "@hookform/resolvers": "^3.9.0",
"@ionic/react": "^8.5.0", "@ionic/pwa-elements": "^3.0.0",
"@ionic/react-router": "^8.5.0", "@ionic/react": "8.8.7",
"@supabase/supabase-js": "^2.0.0", "@ionic/react-router": "8.8.7",
"@supabase/supabase-js": "^2.108.1",
"firebase": "^11.0.0", "firebase": "^11.0.0",
"ionicons": "^7.4.0", "ionicons": "^7.4.0",
"katex": "^0.16.0", "katex": "^0.16.0",
"qrcode.react": "^4.2.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.54.0", "react-hook-form": "^7.54.0",
BIN
View File
Binary file not shown.
+299 -22
View File
@@ -1,7 +1,29 @@
import React from 'react'; import React, { useEffect } from 'react';
import { Redirect, Route } from 'react-router-dom'; import { Redirect, Route, useHistory, useLocation } from 'react-router-dom';
import { IonApp, IonRouterOutlet } from '@ionic/react'; import {
IonApp,
IonIcon,
IonLabel,
IonRouterOutlet,
IonTabBar,
IonTabButton,
IonTabs,
} from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router'; 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/core.css';
import '@ionic/react/css/normalize.css'; import '@ionic/react/css/normalize.css';
@@ -13,28 +35,283 @@ import '@ionic/react/css/text-alignment.css';
import '@ionic/react/css/text-transformation.css'; import '@ionic/react/css/text-transformation.css';
import '@ionic/react/css/flex-utils.css'; import '@ionic/react/css/flex-utils.css';
import '@ionic/react/css/display.css'; import '@ionic/react/css/display.css';
import '@ionic/react/css/palettes/dark.system.css';
import './theme/variables.css'; import './theme/variables.css';
import Home from './pages/Home'; import { setStatusBarStyle, Style } from './utils/statusBar';
import Account from './pages/Account'; import { AuthProvider, useAuth } from './contexts/AuthContext';
import Auth from './pages/Auth'; import ProtectedRoute from './components/ProtectedRoute';
import Stats from './pages/Stats';
import HabitDetail from './pages/HabitDetail';
const App = (): any => ( // Auth Pages
<IonApp> import AuthPage from './pages/AuthPage';
<IonReactRouter> import VerifyEmailPage from './pages/VerifyEmailPage';
<IonRouterOutlet> import ForgotPasswordPage from './pages/ForgotPasswordPage';
<Route exact path="/auth" component={Auth} /> import VerifyResetPage from './pages/VerifyResetPage';
<Route exact path="/home" component={Home} /> import SetupProfilePage from './pages/SetupProfilePage';
<Route exact path="/stats" component={Stats} />
<Route exact path="/habit/:id" component={HabitDetail} /> // Main App Pages
<Route exact path="/account" component={Account} /> import HomePage from './pages/HomePage';
<Route exact path="/" render={() => <Redirect to="/home" />} /> import RecipientsPage from './pages/RecipientsPage';
</IonRouterOutlet> import RecipientFormPage from './pages/RecipientFormPage';
</IonReactRouter> import RecipientDetailPage from './pages/RecipientDetailPage';
</IonApp> 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>
);
};
export default App; export default App;
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+155
View File
@@ -0,0 +1,155 @@
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;
+116
View File
@@ -0,0 +1,116 @@
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;
+121
View File
@@ -0,0 +1,121 @@
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;
+88
View File
@@ -0,0 +1,88 @@
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;
-77
View File
@@ -1,77 +0,0 @@
import React from 'react';
import { IonProgressBar } from '@ionic/react';
interface CustomProgressBarProps {
value: number; // 0 to 1
label?: string;
statusText?: string;
color?: string; // Hex, rgb, or css variable
showPercent?: boolean;
style?: React.CSSProperties;
}
export const CustomProgressBar: React.FC<CustomProgressBarProps> = ({
value,
label,
statusText,
color = 'var(--ion-color-primary)',
showPercent = true,
style,
}) => {
const percentage = Math.round(Math.min(Math.max(value, 0), 1) * 100);
return (
<div style={{ width: '100%', ...style }}>
{/* Label and Status */}
{(label || statusText || showPercent) && (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
fontSize: '0.8rem',
marginBottom: '6px',
fontWeight: '600',
}}
>
<span style={{ color: 'var(--ion-color-medium)' }}>
{label}{' '}
{statusText && (
<span style={{ fontWeight: 'normal', opacity: 0.85 }}>
{statusText}
</span>
)}
</span>
{showPercent && (
<span
style={{
color: color,
fontVariantNumeric: 'tabular-nums',
fontFamily: 'ui-monospace, monospace',
fontWeight: '700',
}}
>
{percentage}%
</span>
)}
</div>
)}
{/* Progress Track */}
<IonProgressBar
value={value}
style={
{
height: '6px',
'--background': 'rgba(148, 163, 184, 0.1)',
'--progress-background': color,
borderRadius: '10px',
transition: 'value 0.3s ease',
} as React.CSSProperties
}
/>
</div>
);
};
export default CustomProgressBar;
+246
View File
@@ -0,0 +1,246 @@
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;
-32
View File
@@ -1,32 +0,0 @@
import React from 'react';
import { IonButton, IonIcon } from '@ionic/react';
import { sparklesOutline } from 'ionicons/icons';
interface EmptyStateProps {
title: string;
message: string;
actionLabel: string;
onAction: () => void;
}
const EmptyState: React.FC<EmptyStateProps> = ({
title,
message,
actionLabel,
onAction,
}) => {
return (
<div className="empty-state-card home-empty-state-card">
<div className="empty-state-icon">
<IonIcon icon={sparklesOutline} />
</div>
<h2>{title}</h2>
<p>{message}</p>
<IonButton className="home-empty-state-button" onClick={onAction}>
{actionLabel}
</IonButton>
</div>
);
};
export default EmptyState;
-108
View File
@@ -1,108 +0,0 @@
import React from 'react';
import { IonButton, IonIcon, IonSpinner } from '@ionic/react';
import {
checkmarkOutline,
ellipseOutline,
flameOutline,
trashOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import type { Tables } from '../database.types';
interface HabitCardProps {
habit: Tables<'habits'>;
completedToday: boolean;
currentStreak: number;
completionRate: number;
onToggle: () => void;
onDelete: () => void;
busy: boolean;
}
const HabitCard: React.FC<HabitCardProps> = ({
habit,
completedToday,
currentStreak,
completionRate,
onToggle,
onDelete,
busy,
}) => {
const history = useHistory();
return (
<div
className={`habit-card habit-card--editorial ${completedToday ? 'is-complete' : ''}`}
role="button"
tabIndex={0}
onClick={() => history.push(`/habit/${habit.id}`)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
history.push(`/habit/${habit.id}`);
}
}}
aria-label={`View details for ${habit.name}`}
>
<button
type="button"
className={`habit-check-button ${completedToday ? 'is-complete' : ''}`}
onClick={(event) => {
event.stopPropagation();
onToggle();
}}
disabled={busy}
aria-label={
completedToday
? `Mark ${habit.name} as not done`
: `Mark ${habit.name} as done`
}
>
{busy ? (
<IonSpinner name="crescent" />
) : completedToday ? (
<IonIcon icon={checkmarkOutline} />
) : (
<IonIcon icon={ellipseOutline} />
)}
</button>
<div className="habit-card-main">
<div className="habit-copy">
<div className="habit-copy-topline">
<h3>{habit.name}</h3>
<span
className="habit-color-pill"
style={{ background: habit.color }}
/>
</div>
<p>
{habit.target_description ||
'Show up today and keep the chain alive.'}
</p>
<div className="habit-metrics-row">
<span>
<IonIcon icon={flameOutline} /> {currentStreak} streak
</span>
<span>{completionRate}% this week</span>
</div>
</div>
</div>
<IonButton
className="habit-delete-button"
fill="clear"
color="medium"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
disabled={busy}
>
<IonIcon icon={trashOutline} />
</IonButton>
</div>
);
};
export default HabitCard;
-153
View File
@@ -1,153 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonInput,
IonItem,
IonLabel,
IonModal,
IonTextarea,
IonTitle,
IonToolbar,
} from '@ionic/react';
interface HabitFormModalProps {
isOpen: boolean;
onDismiss: () => void;
onSubmit: (values: {
name: string;
targetDescription: string;
color: string;
}) => Promise<void>;
initialValues?: {
name: string;
targetDescription: string;
color: string;
};
}
const COLORS = [
'#7c3aed',
'#0f766e',
'#ea580c',
'#dc2626',
'#2563eb',
'#059669',
];
const HabitFormModal: React.FC<HabitFormModalProps> = ({
isOpen,
onDismiss,
onSubmit,
initialValues,
}) => {
const isEditing = !!initialValues;
const [name, setName] = useState(initialValues?.name ?? '');
const [targetDescription, setTargetDescription] = useState(
initialValues?.targetDescription ?? ''
);
const [color, setColor] = useState(initialValues?.color ?? COLORS[0]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const canSubmit = useMemo(
() => name.trim().length > 0 && name.trim().length <= 80,
[name]
);
useEffect(() => {
if (isOpen) {
setName(initialValues?.name ?? '');
setTargetDescription(initialValues?.targetDescription ?? '');
setColor(initialValues?.color ?? COLORS[0]);
setError('');
}
}, [isOpen, initialValues]);
const handleSubmit = async () => {
setError('');
setSaving(true);
try {
await onSubmit({
name: name.trim(),
targetDescription: targetDescription.trim(),
color,
});
setName('');
setTargetDescription('');
setColor(COLORS[0]);
onDismiss();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to save habit.');
} finally {
setSaving(false);
}
};
return (
<IonModal isOpen={isOpen} onDidDismiss={onDismiss}>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonTitle>{isEditing ? 'Edit Habit' : 'New Habit'}</IonTitle>
<IonButtons slot="end">
<IonButton onClick={onDismiss}>Close</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div className="habit-form-grid">
<IonItem lines="none" className="auth-input-item">
<IonInput
label="Habit name"
labelPlacement="stacked"
value={name}
maxlength={80}
placeholder="Drink water"
onIonInput={(e) => setName(e.detail.value ?? '')}
/>
</IonItem>
<IonItem lines="none" className="auth-input-item">
<IonTextarea
label="Target or note"
labelPlacement="stacked"
value={targetDescription}
placeholder="8 glasses, 20 minutes, 30 pushups..."
autoGrow
onIonInput={(e) => setTargetDescription(e.detail.value ?? '')}
/>
</IonItem>
<div>
<IonLabel className="color-label">Color</IonLabel>
<div className="habit-color-row">
{COLORS.map((swatch) => (
<button
key={swatch}
type="button"
className={`habit-color-dot ${color === swatch ? 'selected' : ''}`}
style={{ background: swatch }}
onClick={() => setColor(swatch)}
/>
))}
</div>
</div>
{error ? <p className="auth-feedback error-text">{error}</p> : null}
<IonButton
expand="block"
onClick={handleSubmit}
disabled={!canSubmit || saving}
>
{saving ? 'Saving…' : isEditing ? 'Save Changes' : 'Create Habit'}
</IonButton>
</div>
</IonContent>
</IonModal>
);
};
export default HabitFormModal;
+86
View File
@@ -0,0 +1,86 @@
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;
+210
View File
@@ -0,0 +1,210 @@
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;
+82
View File
@@ -0,0 +1,82 @@
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;
-69
View File
@@ -1,69 +0,0 @@
import React from 'react';
import {
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonBackButton,
useIonViewWillEnter,
} from '@ionic/react';
import { setStatusBarStyle, Style } from '../utils/statusBar';
interface PageHeaderProps {
title: string;
showBackButton?: boolean;
defaultBackHref?: string;
endActions?: React.ReactNode;
style?: React.CSSProperties;
toolbarStyle?: React.CSSProperties;
}
export const PageHeader: React.FC<PageHeaderProps> = ({
title,
showBackButton = false,
defaultBackHref = '/home',
endActions,
style,
toolbarStyle,
}) => {
// Use Ionic lifecycle hook to set status bar style automatically for pages using PageHeader
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark); // Dark text/icons for light-colored headers
});
return (
<IonHeader className="ion-no-border" style={style}>
<IonToolbar
style={
{
'--background': '#ffffff',
'--color': 'var(--ion-color-dark)',
'--border-width': '0 0 1px 0',
'--border-color': 'rgba(0, 0, 0, 0.05)',
...toolbarStyle,
} as React.CSSProperties
}
>
{showBackButton && (
<IonButtons slot="start">
<IonBackButton defaultHref={defaultBackHref} />
</IonButtons>
)}
<IonTitle
style={{
fontWeight: '800',
fontSize: '1.1rem',
letterSpacing: '-0.3px',
}}
>
{title}
</IonTitle>
{endActions && <IonButtons slot="end">{endActions}</IonButtons>}
</IonToolbar>
</IonHeader>
);
};
export default PageHeader;
+40
View File
@@ -0,0 +1,40 @@
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;
+94
View File
@@ -0,0 +1,94 @@
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;
+239
View File
@@ -0,0 +1,239 @@
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;
+121
View File
@@ -0,0 +1,121 @@
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;
-80
View File
@@ -1,80 +0,0 @@
import React from 'react';
import { IonIcon } from '@ionic/react';
interface RevisionInstrumentCardProps {
title: string;
subtitle?: string;
icon: string;
iconColor: string;
iconBgColor: string;
onClick: () => void;
style?: React.CSSProperties;
}
export const RevisionInstrumentCard: React.FC<RevisionInstrumentCardProps> = ({
title,
subtitle,
icon,
iconColor,
iconBgColor,
onClick,
style,
}) => {
return (
<div
onClick={onClick}
style={{
background: '#ffffff',
border: 'none',
borderRadius: '14px',
padding: '14px 10px',
textAlign: 'center',
cursor: 'pointer',
boxShadow: 'none',
transition: 'transform 0.15s ease',
...style,
}}
className="ion-activatable"
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '10px',
background: iconBgColor,
color: iconColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 8px auto',
}}
>
<IonIcon icon={icon} style={{ fontSize: '18px' }} />
</div>
<span
style={{
fontSize: '0.8rem',
fontWeight: '700',
color: 'var(--ion-color-dark)',
display: 'block',
}}
>
{title}
</span>
{subtitle && (
<span
style={{
fontSize: '0.65rem',
color: 'var(--ion-color-medium)',
display: 'block',
marginTop: '2px',
}}
>
{subtitle}
</span>
)}
</div>
);
};
export default RevisionInstrumentCard;
+115
View File
@@ -0,0 +1,115 @@
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;
+55
View File
@@ -0,0 +1,55 @@
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;
-47
View File
@@ -1,47 +0,0 @@
import React from 'react';
import { IonProgressBar } from '@ionic/react';
interface StatsHabitCardProps {
name: string;
color: string;
completedCount: number;
totalDays: number;
streak: number;
}
const StatsHabitCard: React.FC<StatsHabitCardProps> = ({
name,
color,
completedCount,
totalDays,
streak,
}) => {
const ratio = totalDays > 0 ? completedCount / totalDays : 0;
const percentage = Math.round(ratio * 100);
return (
<div className="stats-card">
<div className="stats-card-header">
<div>
<h3>{name}</h3>
<p>
{completedCount} of {totalDays} days completed
</p>
</div>
<div className="stats-streak-badge" style={{ color }}>
{streak}🔥
</div>
</div>
<IonProgressBar
value={ratio}
style={{ '--progress-background': color } as React.CSSProperties}
/>
<div className="stats-card-footer">
<span>{percentage}% consistency</span>
<span>Current streak {streak}</span>
</div>
</div>
);
};
export default StatsHabitCard;
+313
View File
@@ -0,0 +1,313 @@
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>
);
};
+465 -50
View File
@@ -14,122 +14,537 @@ export type Database = {
} }
public: { public: {
Tables: { Tables: {
habit_completions: { activity_events: {
Row: { Row: {
completed_on: string amount: number | null
created_at: string created_at: string
habit_id: string event_at: string
event_type: string
id: string id: string
metadata: Json
order_id: string | null
recipient_id: string | null
subtitle: string
title: string
user_id: string user_id: string
} }
Insert: { Insert: {
completed_on: string amount?: number | null
created_at?: string created_at?: string
habit_id: string event_at?: string
event_type: string
id?: string id?: string
metadata?: Json
order_id?: string | null
recipient_id?: string | null
subtitle: string
title: string
user_id: string user_id: string
} }
Update: { Update: {
completed_on?: string amount?: number | null
created_at?: string created_at?: string
habit_id?: string event_at?: string
event_type?: string
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 user_id?: string
} }
Relationships: [ Relationships: [
{ {
foreignKeyName: "habit_completions_habit_id_fkey" foreignKeyName: "care_alerts_schedule_id_fkey"
columns: ["habit_id"] columns: ["schedule_id"]
isOneToOne: false isOneToOne: false
referencedRelation: "habits" referencedRelation: "support_schedules"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
] ]
} }
habits: { merchants: {
Row: { Row: {
color: string 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
created_at: string created_at: string
fcm_token: string | null fcm_token: string | null
first_name: string
full_name: string
id: string id: string
name: string last_name: string
target_description: string | null 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
user_id: string user_id: string
} }
Insert: { Insert: {
color?: string archived_at?: string | null
city: string
country: string
created_at?: string created_at?: string
fcm_token?: string | null first_name: string
id?: string id?: string
name: string is_active?: boolean
target_description?: string | null 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
user_id: string user_id: string
} }
Update: { Update: {
color?: string archived_at?: string | null
city?: string
country?: string
created_at?: string created_at?: string
fcm_token?: string | null first_name?: string
id?: string id?: string
name?: string is_active?: boolean
target_description?: string | null 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
user_id?: string user_id?: string
} }
Relationships: [] Relationships: []
} }
notes: { support_orders: {
Row: { Row: {
content: string amount: number
created_at: string created_at: string
currency: string
delivery_channel: string
id: string id: string
is_favorite: boolean merchant_id: string | null
title: string 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 user_id: string
} }
Insert: { Insert: {
content: string amount: number
created_at?: string created_at?: string
currency?: string
delivery_channel?: string
id?: string id?: string
is_favorite?: boolean merchant_id?: string | null
title: string meter_number?: string | null
user_id?: string 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: { Update: {
content?: string amount?: number
created_at?: string created_at?: string
currency?: string
delivery_channel?: string
id?: string id?: string
is_favorite?: boolean merchant_id?: string | null
title?: string 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 user_id?: string
} }
Relationships: [] 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"]
},
]
} }
todos: { support_schedules: {
Row: { Row: {
created_at: string created_at: string | null
description: string | null frequency_interval: number
due_date: string | null frequency_unit: string
id: string id: string
is_completed: boolean is_active: boolean
title: string last_supported_at: string | null
next_due_at: string
recipient_id: string
service_type: string
updated_at: string | null
user_id: string user_id: string
} }
Insert: { Insert: {
created_at?: string created_at?: string | null
description?: string | null frequency_interval: number
due_date?: string | null frequency_unit: string
id?: string id?: string
is_completed?: boolean is_active?: boolean
title: string 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
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 user_id?: string
} }
Relationships: [
{
foreignKeyName: "support_schedules_recipient_id_fkey"
columns: ["recipient_id"]
isOneToOne: false
referencedRelation: "recipients"
referencedColumns: ["id"]
},
]
}
voucher_redemptions: {
Row: {
created_at: string
id: string
merchant_id: string
order_id: string
receipt_reference: string | null
redeemed_amount: number
redeemed_at: string
voucher_id: string
}
Insert: {
created_at?: string
id?: string
merchant_id: string
order_id: string
receipt_reference?: string | null
redeemed_amount: number
redeemed_at?: string
voucher_id: string
}
Update: { Update: {
created_at?: string created_at?: string
description?: string | null
due_date?: string | null
id?: string id?: string
is_completed?: boolean merchant_id?: string
title?: string order_id?: string
user_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
} }
Relationships: [] Relationships: []
} }
+22 -15
View File
@@ -5,7 +5,7 @@ import { Capacitor } from '@capacitor/core';
import App from './App'; import App from './App';
// ── Ionic initialisation ────────────────────────────────────────────────────── // ── Ionic initialisation ──────────────────────────────────────────────────────
// ?mode=md|ios overrides (used by AppSuite preview); otherwise follow the platform. // ?mode=md|ios overrides (used by Appcakes preview); otherwise follow the platform.
const _urlMode = new URLSearchParams(window.location.search).get('mode'); const _urlMode = new URLSearchParams(window.location.search).get('mode');
const _platform = Capacitor.getPlatform(); // 'ios' | 'android' | 'web' const _platform = Capacitor.getPlatform(); // 'ios' | 'android' | 'web'
setupIonicReact({ setupIonicReact({
@@ -13,7 +13,7 @@ setupIonicReact({
}); });
// Derive the studio parent origin dynamically so this works in both dev // Derive the studio parent origin dynamically so this works in both dev
// (parent at localhost:3000) and production (parent at appcakes.qqura.com). // (parent at localhost:3000) and production (parent at studio.appcakes.dev).
const studioOrigin = (() => { const studioOrigin = (() => {
try { try {
if (document.referrer) return new URL(document.referrer).origin; if (document.referrer) return new URL(document.referrer).origin;
@@ -49,23 +49,25 @@ if (import.meta.hot) {
} }
// ── Session bridge ──────────────────────────────────────────────────────────── // ── Session bridge ────────────────────────────────────────────────────────────
// Post auth session to the AppSuite parent frame so the AI can test auth-protected // Post auth session to the Appcakes parent frame so the AI can test auth-protected
// Edge Functions. Uses import.meta.glob so Vite never errors if supabase.ts is absent. // Edge Functions. Uses import.meta.glob so Vite never errors if supabase.ts is absent.
(async () => { (async () => {
const mods = import.meta.glob('./supabase.ts', { eager: false }); const mods = import.meta.glob('./supabase.ts', { eager: false });
if ('./supabase.ts' in mods) { if ('./supabase.ts' in mods) {
try { try {
const { supabase } = await mods['./supabase.ts']() as { supabase: any }; const { supabase } = await mods['./supabase.ts']() as { supabase: any };
supabase.auth.onAuthStateChange((_event: unknown, session: any) => { // supabase.auth.onAuthStateChange((_event: unknown, session: any) => {
window.parent.postMessage( // setTimeout(() => {
{ // window.parent.postMessage(
type: '__apsuite_session', // {
access_token: session?.access_token ?? null, // type: '__apsuite_session',
email: session?.user?.email ?? null, // access_token: session?.access_token ?? null,
}, // email: session?.user?.email ?? null,
studioOrigin, // },
); // studioOrigin,
}); // );
// }, 0);
// });
} catch { } catch {
// supabase client failed to initialise — session bridge unavailable // supabase client failed to initialise — session bridge unavailable
} }
@@ -73,12 +75,17 @@ if (import.meta.hot) {
})(); })();
// ── Runtime error reporting ─────────────────────────────────────────────────── // ── Runtime error reporting ───────────────────────────────────────────────────
// Forward uncaught errors to the AppSuite dev server so the AI can read them. // Forward uncaught errors to the Appcakes dev server so the AI can read them.
// Extract projectId from the preview URL (/preview/{projectId}/) so errors are
// scoped per-project on the server rather than mixed into a global queue.
const _previewMatch = window.location.pathname.match(/\/preview\/([^/]+)/);
const _projectId = _previewMatch?.[1] ?? null;
function reportError(message: string, stack?: string) { function reportError(message: string, stack?: string) {
fetch(`${studioOrigin}/api/agent/runtime-error`, { fetch(`${studioOrigin}/api/agent/runtime-error`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, stack }), body: JSON.stringify({ message, stack, projectId: _projectId }),
}).catch(() => {}); }).catch(() => {});
} }
-196
View File
@@ -1,196 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
IonButton,
IonContent,
IonHeader,
IonIcon,
IonItem,
IonLabel,
IonPage,
IonTitle,
IonToolbar,
IonText,
} from '@ionic/react';
import {
waterOutline,
fitnessOutline,
shieldCheckmarkOutline,
} from 'ionicons/icons';
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
import { Capacitor } from '@capacitor/core';
import { Redirect as RedirectRR, useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import {
Style,
setStatusBarBackground,
setStatusBarStyle,
} from '../utils/statusBar';
import { useIonViewWillEnter } from '@ionic/react';
import runnerDrinkingWaterImage from '../assets/runner-drinking-water-photorealistic.png';
const RedirectAny = RedirectRR as any;
const Account: React.FC = () => {
const history = useHistory();
const [sessionChecked, setSessionChecked] = useState(false);
const [email, setEmail] = useState<string | null>(null);
const [signingOut, setSigningOut] = useState(false);
const [signOutError, setSignOutError] = useState('');
useEffect(() => {
let mounted = true;
supabase.auth.getSession().then(({ data }) => {
if (!mounted) return;
setEmail(data.session?.user.email ?? null);
setSessionChecked(true);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
if (!mounted) return;
setEmail(session?.user.email ?? null);
setSessionChecked(true);
if (!session) {
history.replace('/auth');
}
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [history]);
const handleSignOut = async () => {
setSignOutError('');
setSigningOut(true);
try {
const { error } = await supabase.auth.signOut();
if (error) {
setSignOutError(error.message);
return;
}
if (Capacitor.isNativePlatform()) {
try {
await FirebaseAuthentication.signOut();
} catch {
// Ignore if no native Google session exists.
}
}
setEmail(null);
history.replace('/auth');
} catch (error) {
setSignOutError(
error instanceof Error
? error.message
: 'Unable to sign out right now.',
);
} finally {
setSigningOut(false);
}
};
if (sessionChecked && !email) {
return <RedirectAny to="/auth" />;
}
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonTitle>Account</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<div className="dashboard-shell ion-padding account-shell">
<section className="account-hero-card">
<div className="account-hero-image-wrap">
<img
src={runnerDrinkingWaterImage}
alt="Runner drinking water after a run"
className="account-hero-image"
/>
<div className="account-hero-overlay" />
</div>
<div className="account-hero-content">
<span className="eyebrow">Recovery & account</span>
<h1>{email ?? 'Loading...'}</h1>
<p>
Stay hydrated, stay consistent, and keep your habit history
safely synced across sessions.
</p>
<div className="account-highlight-row">
<div className="account-highlight-chip">
<IonIcon icon={waterOutline} />
<span>Recovery mindset</span>
</div>
<div className="account-highlight-chip subtle">
<IonIcon icon={fitnessOutline} />
<span>Built for routines</span>
</div>
</div>
</div>
</section>
<div className="stats-card account-details-card">
<IonItem lines="none" className="account-info-item">
<IonLabel>
<h2>Email</h2>
<p>{email ?? '—'}</p>
</IonLabel>
</IonItem>
<div className="account-trust-row">
<div className="account-trust-icon">
<IonIcon icon={shieldCheckmarkOutline} />
</div>
<div>
<h3>Your data is synced</h3>
<p>
Sign in to keep your streaks and progress available whenever
you come back.
</p>
</div>
</div>
<IonButton
expand="block"
className="account-primary-button"
routerLink="/home"
>
Back to Habits
</IonButton>
{signOutError ? (
<IonText color="danger">
<p className="auth-feedback">{signOutError}</p>
</IonText>
) : null}
<div></div>
<IonButton
expand="block"
fill="outline"
color="danger"
className="account-secondary-button"
onClick={handleSignOut}
disabled={signingOut}
>
{signingOut ? 'Signing Out…' : 'Sign Out'}
</IonButton>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default Account;
+722
View File
@@ -0,0 +1,722 @@
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;
-398
View File
@@ -1,398 +0,0 @@
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;
+542
View File
@@ -0,0 +1,542 @@
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;
+344
View File
@@ -0,0 +1,344 @@
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;
+207
View File
@@ -0,0 +1,207 @@
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;
-623
View File
@@ -1,623 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
IonAlert,
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonPage,
IonSkeletonText,
IonTitle,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { IonBackButton } from '@ionic/react';
import {
checkmarkOutline,
createOutline,
ellipseOutline,
flameOutline,
sparklesOutline,
trashOutline,
trendingUpOutline,
} from 'ionicons/icons';
import { Redirect, useHistory, useParams } from 'react-router-dom';
import type { Tables } from '../database.types';
import { supabase } from '../supabase';
import HabitFormModal from '../components/HabitFormModal';
import {
Style,
setStatusBarBackground,
setStatusBarStyle,
} from '../utils/statusBar';
interface HabitWithCompletions extends Tables<'habits'> {
completions: Tables<'habit_completions'>[];
}
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
const calculateStreak = (dates: string[]) => {
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
let streak = 0;
let cursor = new Date();
if (!unique.includes(formatDate(cursor))) {
cursor.setDate(cursor.getDate() - 1);
}
while (unique.includes(formatDate(cursor))) {
streak += 1;
cursor.setDate(cursor.getDate() - 1);
}
return streak;
};
const HabitDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const history = useHistory();
const [sessionChecked, setSessionChecked] = useState(false);
const [userId, setUserId] = useState<string | null>(null);
const [habit, setHabit] = useState<HabitWithCompletions | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const loadHabit = useCallback(async () => {
setError('');
const {
data: { session },
error: sessionError,
} = await supabase.auth.getSession();
if (sessionError) {
setError(sessionError.message);
setLoading(false);
return;
}
if (!session?.user) {
setUserId(null);
setSessionChecked(true);
setLoading(false);
return;
}
setUserId(session.user.id);
setSessionChecked(true);
const { data, error: habitError } = await supabase
.from('habits')
.select('*, completions:habit_completions(*)')
.eq('id', id)
.single();
if (habitError) {
if (habitError.code === 'PGRST116') {
setHabit(null);
} else {
setError(habitError.message);
}
setLoading(false);
return;
}
setHabit(data as HabitWithCompletions);
setLoading(false);
}, [id]);
useEffect(() => {
loadHabit();
}, [loadHabit]);
const completionDates = useMemo(
() => habit?.completions.map((c) => c.completed_on) ?? [],
[habit],
);
const today = formatDate(new Date());
const doneToday = completionDates.includes(today);
const currentStreak = useMemo(
() => calculateStreak(completionDates),
[completionDates],
);
const totalCompletions = completionDates.length;
// Calculate longest streak
const longestStreak = useMemo(() => {
const unique = Array.from(new Set(completionDates)).sort((a, b) =>
a.localeCompare(b),
);
if (unique.length === 0) return 0;
let maxStreak = 1;
let current = 1;
for (let i = 1; i < unique.length; i++) {
const prev = new Date(unique[i - 1]);
const curr = new Date(unique[i]);
const diffDays =
(curr.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24);
if (diffDays === 1) {
current += 1;
maxStreak = Math.max(maxStreak, current);
} else {
current = 1;
}
}
return maxStreak;
}, [completionDates]);
// Days since habit creation (or first completion, whichever is earlier)
const daysSinceStart = useMemo(() => {
const startDate = habit
? new Date(
Math.min(
new Date(habit.created_at).getTime(),
...completionDates.map((d) => new Date(d).getTime()),
Date.now(),
),
)
: new Date();
const diff = Math.ceil(
(Date.now() - startDate.getTime()) / (1000 * 60 * 60 * 24),
);
return Math.max(diff, 1);
}, [habit, completionDates]);
const consistencyRate = Math.round((totalCompletions / daysSinceStart) * 100);
// 30-day heatmap data
const heatmapDays = useMemo(() => {
return Array.from({ length: 30 }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - (29 - i));
const key = formatDate(date);
return {
key,
completed: completionDates.includes(key),
isToday: key === today,
};
});
}, [completionDates, today]);
const toggleToday = async () => {
if (!userId || !habit) return;
setBusy(true);
if (doneToday) {
const existing = habit.completions.find((c) => c.completed_on === today);
if (existing) {
const { error: deleteError } = await supabase
.from('habit_completions')
.delete()
.eq('id', existing.id);
if (deleteError) setError(deleteError.message);
}
} else {
const { error: insertError } = await supabase
.from('habit_completions')
.insert({
habit_id: habit.id,
user_id: userId,
completed_on: today,
});
if (insertError) setError(insertError.message);
}
await loadHabit();
setBusy(false);
};
const updateHabit = async (values: {
name: string;
targetDescription: string;
color: string;
}) => {
if (!userId || !habit) throw new Error('Habit not found.');
const { error: updateError } = await supabase
.from('habits')
.update({
name: values.name,
target_description: values.targetDescription || null,
color: values.color,
})
.eq('id', habit.id);
if (updateError) throw updateError;
await loadHabit();
};
const deleteHabit = async () => {
if (!habit) return;
setBusy(true);
const { error: deleteError } = await supabase
.from('habits')
.delete()
.eq('id', habit.id);
if (deleteError) {
setError(deleteError.message);
setBusy(false);
return;
}
history.push('/home');
};
if (sessionChecked && !userId) {
return <Redirect to="/auth" />;
}
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/home" />
</IonButtons>
<IonTitle>{habit?.name ?? 'Habit'}</IonTitle>
<IonButtons slot="end">
{habit && (
<>
<IonButton fill="clear" onClick={() => setShowEditModal(true)}>
<IonIcon icon={createOutline} />
</IonButton>
<IonButton
fill="clear"
color="danger"
onClick={() => setShowDeleteAlert(true)}
>
<IonIcon icon={trashOutline} />
</IonButton>
</>
)}
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<div className="dashboard-shell ion-padding">
{/* Loading state */}
{loading ? (
<>
<IonSkeletonText
animated
style={{ width: '60%', height: '24px', borderRadius: '8px' }}
/>
<IonSkeletonText
animated
style={{
width: '100%',
height: '120px',
borderRadius: '24px',
}}
/>
<IonSkeletonText
animated
style={{
width: '100%',
height: '160px',
borderRadius: '24px',
}}
/>
<IonSkeletonText
animated
style={{
width: '100%',
height: '100px',
borderRadius: '24px',
}}
/>
</>
) : error ? (
/* Error state */
<div className="inline-message error-card">
<h3>Something went wrong</h3>
<p>{error}</p>
<IonButton onClick={loadHabit}>Try Again</IonButton>
</div>
) : !habit ? (
/* Not found state */
<div className="inline-message">
<h3>Habit not found</h3>
<p>
This habit may have been deleted or you don&apos;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;
+12 -475
View File
@@ -1,493 +1,30 @@
import React, { import React from "react";
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { import {
IonButton,
IonButtons,
IonContent, IonContent,
IonHeader, IonHeader,
IonIcon,
IonPage, IonPage,
IonRefresher, IonTitle,
IonRefresherContent,
IonSkeletonText,
IonToolbar, 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';
interface HabitWithMeta extends Tables<'habits'> { const Home: React.FC = () => (
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> <IonPage>
<IonHeader className="ion-no-border home-header"> <IonHeader translucent className="home-header">
<IonToolbar> <IonToolbar className="home-toolbar">
<IonButtons slot="end"> <IonTitle>Home</IonTitle>
<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> </IonToolbar>
</IonHeader> </IonHeader>
<IonContent fullscreen className="home-content"> <IonContent fullscreen className="home-content">
<IonRefresher <div className="home-shell minimal-home-shell">
slot="fixed" <section className="minimal-welcome-card subtle-welcome-card">
onIonRefresh={async (e) => { <h1>Welcome to your new app</h1>
await loadDashboard(); <p className="minimal-subtitle">
e.detail.complete(); Chat with the assistant to start adding features and pages
}}
>
<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&apos;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> </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> </section>
</>
)}
</div> </div>
<HabitFormModal
isOpen={showCreateModal}
onDismiss={() => setShowCreateModal(false)}
onSubmit={createHabit}
/>
</IonContent> </IonContent>
</IonPage> </IonPage>
); );
};
export default Home; export default Home;
+900
View File
@@ -0,0 +1,900 @@
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;
+494
View File
@@ -0,0 +1,494 @@
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;
File diff suppressed because it is too large Load Diff
+253
View File
@@ -0,0 +1,253 @@
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;
+503
View File
@@ -0,0 +1,503 @@
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;
File diff suppressed because it is too large Load Diff
+740
View File
@@ -0,0 +1,740 @@
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;
+347
View File
@@ -0,0 +1,347 @@
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;
+367
View File
@@ -0,0 +1,367 @@
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;
+78
View File
@@ -0,0 +1,78 @@
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;
-174
View File
@@ -1,174 +0,0 @@
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&apos;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;
File diff suppressed because it is too large Load Diff
+297
View File
@@ -0,0 +1,297 @@
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;
+317
View File
@@ -0,0 +1,317 @@
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;
+295
View File
@@ -0,0 +1,295 @@
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;
+507
View File
@@ -0,0 +1,507 @@
/* 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;
}
+959
View File
@@ -0,0 +1,959 @@
.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);
}
}
+1250
View File
File diff suppressed because it is too large Load Diff
+349
View File
@@ -0,0 +1,349 @@
/* 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;
}
File diff suppressed because it is too large Load Diff
+949
View File
@@ -0,0 +1,949 @@
/* 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 -2
View File
@@ -2,6 +2,6 @@ import { createClient } from '@supabase/supabase-js';
import type { Database } from './database.types'; import type { Database } from './database.types';
export const supabase = createClient<Database>( export const supabase = createClient<Database>(
'https://zillycfjfbzofltgyjwy.supabase.co', 'https://pdtnuymihtxnhmzaybif.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InppbGx5Y2ZqZmJ6b2ZsdGd5and5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkxMjYzODQsImV4cCI6MjA5NDcwMjM4NH0.BB4ccBEQI2krc7SseC73234Nfya-92VGcTELNQdzt50', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBkdG51eW1paHR4bmhtemF5YmlmIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODE5NTQwOTYsImV4cCI6MjA5NzUzMDA5Nn0.k-3YKruldYrh88JSt4E-Q8veUbnAE6hrsxlrWmwjzeM',
); );
+51
View File
@@ -0,0 +1,51 @@
// 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;
}
+203 -842
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
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;
};
+615
View File
@@ -0,0 +1,615 @@
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),
};
};
+129
View File
@@ -0,0 +1,129 @@
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),
};
};
+61
View File
@@ -0,0 +1,61 @@
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
);
}
};
+13 -4
View File
@@ -2,13 +2,17 @@ import { StatusBar, Style } from '@capacitor/status-bar';
export { Style }; export { Style };
const _origin = (() => { const _origin = (() => {
try { if (document.referrer) return new URL(document.referrer).origin; } catch {} try {
if (document.referrer) return new URL(document.referrer).origin;
} catch {}
return window.location.origin; return window.location.origin;
})(); })();
/** Set status bar icon/text colour. Use instead of StatusBar.setStyle directly. */ /** Set status bar icon/text colour. Use instead of StatusBar.setStyle directly. */
export async function setStatusBarStyle(style: Style): Promise<void> { export async function setStatusBarStyle(style: Style): Promise<void> {
try { await StatusBar.setStyle({ style }); } catch {} try {
await StatusBar.setStyle({ style });
} catch {}
if (window.parent !== window) { if (window.parent !== window) {
window.parent.postMessage({ type: '__apsuite_statusbar', style }, _origin); window.parent.postMessage({ type: '__apsuite_statusbar', style }, _origin);
} }
@@ -16,8 +20,13 @@ export async function setStatusBarStyle(style: Style): Promise<void> {
/** Set status bar background colour (Android). Use instead of StatusBar.setBackgroundColor directly. */ /** Set status bar background colour (Android). Use instead of StatusBar.setBackgroundColor directly. */
export async function setStatusBarBackground(color: string): Promise<void> { 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) { if (window.parent !== window) {
window.parent.postMessage({ type: '__apsuite_statusbar_bg', color }, _origin); window.parent.postMessage(
{ type: '__apsuite_statusbar_bg', color },
_origin
);
} }
} }
@@ -0,0 +1,77 @@
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' },
});
}
});
@@ -0,0 +1,169 @@
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' },
});
}
});
+1 -1
View File
@@ -7,7 +7,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": false, "esModuleInterop": false,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"strict": true, "strict": false,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Node", "moduleResolution": "Node",
+17 -3
View File
@@ -1,3 +1,6 @@
// NOTE: @ionic/react and @ionic/react-router are pinned to 8.8.7 in package.json.
// 8.8.8 introduced a regression that breaks IonRouterOutlet page transition animations.
// Do not upgrade until the upstream fix is confirmed.
import { defineConfig, type Plugin } from 'vite'; import { defineConfig, type Plugin } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
@@ -5,16 +8,27 @@ const injectRouterBasename: Plugin = {
name: 'inject-router-basename', name: 'inject-router-basename',
enforce: 'pre', enforce: 'pre',
transform(code, id) { transform(code, id) {
if (!/\.[jt]sx$/.test(id)) return; if (!/\.[jt]sx$/.test(id)) return null;
return code.replace( const next = code.replace(
/<IonReactRouter(?![^>]*basename)([^>]*)>/g, /<IonReactRouter(?![^>]*basename)([^>]*)>/g,
'<IonReactRouter$1 basename={import.meta.env.BASE_URL}>', '<IonReactRouter$1 basename={import.meta.env.BASE_URL}>',
); );
return next === code ? null : next;
},
};
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({ export default defineConfig({
plugins: [injectRouterBasename, react()], plugins: [injectRouterBasename, hideScrollbars, react()],
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 8100, port: 8100,
+3791 -1779
View File
File diff suppressed because it is too large Load Diff