Compare commits

..

1 Commits

Author SHA1 Message Date
AppCakes 98554b0086 build: 444813b5-5daf-49b2-bf1a-7e4caeab4e17 2026-07-04 11:56:40 +00:00
138 changed files with 19705 additions and 6753 deletions
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.inspot.app" applicationId "com.kumusha.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
+1 -12
View File
@@ -1,11 +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.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -30,15 +25,9 @@
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.inspot.app" /> <data android:scheme="com.kumusha.app" />
</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.inspot.app" />
</intent-filter>
</activity> </activity>
<meta-data <meta-data
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 742 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 742 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#FFFFFF</color> <color name="ic_launcher_background">#FFFFFF</color>
</resources> </resources>
+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">InSpot</string> <string name="app_name">kumusha</string>
<string name="title_activity_main">InSpot</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>
+5 -2
View File
@@ -1,14 +1,17 @@
import type { CapacitorConfig } from '@capacitor/cli'; import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = { const config: CapacitorConfig = {
appId: 'com.inspot.app', appId: 'com.kumusha.app',
appName: 'InSpot', appName: 'kumusha',
webDir: 'dist', webDir: 'dist',
plugins: { plugins: {
FirebaseAuthentication: { FirebaseAuthentication: {
skipNativeAuth: false, skipNativeAuth: false,
providers: [], providers: [],
}, },
PushNotifications: {
presentationOptions: ['badge', 'sound', 'banner'],
},
}, },
}; };
+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.inspot.app; 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.inspot.app; 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;
+15 -15
View File
@@ -13,21 +13,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return true return true
} }
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
Messaging.messaging().token { token, error in
if let error = error {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
} else if let token = token {
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: token)
}
}
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
}
func applicationWillResignActive(_ application: UIApplication) { 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. // 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. // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
@@ -63,4 +48,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
} }
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
Messaging.messaging().token { token, error in
if let error = error {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
} else if let token = token {
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: token)
}
}
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
}
} }
@@ -1,122 +1,14 @@
{ {
"images": [ "images" : [
{ {
"idiom": "iphone", "filename" : "AppIcon-512@2x.png",
"scale": "1x", "idiom" : "universal",
"size": "20x20", "platform" : "ios",
"filename": "icon-20.png" "size" : "1024x1024"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "20x20",
"filename": "icon-20.png"
},
{
"idiom": "iphone",
"scale": "1x",
"size": "29x29",
"filename": "icon-29.png"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "29x29",
"filename": "icon-29.png"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "40x40",
"filename": "icon-40.png"
},
{
"idiom": "iphone",
"scale": "2x",
"size": "20x20",
"filename": "icon-40.png"
},
{
"idiom": "iphone",
"scale": "2x",
"size": "29x29",
"filename": "icon-58.png"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "29x29",
"filename": "icon-58.png"
},
{
"idiom": "iphone",
"scale": "3x",
"size": "20x20",
"filename": "icon-60.png"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "76x76",
"filename": "icon-76.png"
},
{
"idiom": "iphone",
"scale": "2x",
"size": "40x40",
"filename": "icon-80.png"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "40x40",
"filename": "icon-80.png"
},
{
"idiom": "iphone",
"scale": "3x",
"size": "29x29",
"filename": "icon-87.png"
},
{
"idiom": "iphone",
"scale": "2x",
"size": "60x60",
"filename": "icon-120.png"
},
{
"idiom": "iphone",
"scale": "3x",
"size": "40x40",
"filename": "icon-120.png"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "76x76",
"filename": "icon-152.png"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "83.5x83.5",
"filename": "icon-167.png"
},
{
"idiom": "iphone",
"scale": "3x",
"size": "60x60",
"filename": "icon-180.png"
},
{
"idiom": "ios-marketing",
"scale": "1x",
"size": "1024x1024",
"filename": "icon-1024.png"
} }
], ],
"info": { "info" : {
"author": "appsuite", "author" : "xcode",
"version": 1 "version" : 1
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 979 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

+15 -30
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>InSpot</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,35 +47,20 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true/> <true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.inspot.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.inspot.app</string>
</array>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.inspot.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.inspot.app</string>
</array>
</dict>
</array>
<key>NSCameraUsageDescription</key>
<string>InSpot needs camera access to let you capture and share reels of local spots.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>InSpot needs photo library access to let you upload profile photos and share reels from your gallery.</string> <string>Kumusha needs access to your photo library so you can add profile and recipient photos.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>InSpot needs microphone access to record sound for your reels.</string> <string>Kumusha needs camera access so you can add profile and recipient photos.</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>CFBundleURLTypes</key>
<string>InSpot needs your location to discover nearby spots and social connections.</string> <array>
<dict>
<key>CFBundleURLName</key>
<string>com.kumusha.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.kumusha.app</string>
</array>
</dict>
</array>
</dict> </dict>
</plist> </plist>
+4 -1
View File
@@ -14,11 +14,14 @@
"@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",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@ionic/pwa-elements": "^3.0.0", "@ionic/pwa-elements": "^3.0.0",
@@ -28,7 +31,7 @@
"firebase": "^11.0.0", "firebase": "^11.0.0",
"ionicons": "^7.4.0", "ionicons": "^7.4.0",
"katex": "^0.16.0", "katex": "^0.16.0",
"lottie-react": "^2.4.1", "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",
+279 -115
View File
@@ -1,5 +1,5 @@
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 { import {
IonApp, IonApp,
IonIcon, IonIcon,
@@ -10,144 +10,308 @@ import {
IonTabs, IonTabs,
} from '@ionic/react'; } 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 { import {
flameOutline, heart,
locationOutline, heartOutline,
chatbubblesOutline, listOutline,
peopleOutline,
personOutline, personOutline,
} from 'ionicons/icons'; } from 'ionicons/icons';
/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css'; import '@ionic/react/css/core.css';
import '@ionic/react/css/normalize.css'; import '@ionic/react/css/normalize.css';
import '@ionic/react/css/structure.css'; import '@ionic/react/css/structure.css';
import '@ionic/react/css/typography.css'; import '@ionic/react/css/typography.css';
/* Optional CSS utils that can be commented out */
import '@ionic/react/css/padding.css'; import '@ionic/react/css/padding.css';
import '@ionic/react/css/float-elements.css'; import '@ionic/react/css/float-elements.css';
import '@ionic/react/css/text-alignment.css'; import '@ionic/react/css/text-alignment.css';
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';
/* Theme variables */
import './theme/variables.css'; import './theme/variables.css';
/* Pages */ import { setStatusBarStyle, Style } from './utils/statusBar';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
// Auth Pages
import AuthPage from './pages/AuthPage'; import AuthPage from './pages/AuthPage';
import VerifyEmailPage from './pages/VerifyEmailPage'; import VerifyEmailPage from './pages/VerifyEmailPage';
import ForgotPasswordPage from './pages/ForgotPasswordPage'; import ForgotPasswordPage from './pages/ForgotPasswordPage';
import NewPasswordPage from './pages/NewPasswordPage'; import VerifyResetPage from './pages/VerifyResetPage';
import OnboardingProfilePage from './pages/OnboardingProfilePage'; import SetupProfilePage from './pages/SetupProfilePage';
import IcebreakerPage from './pages/IcebreakerPage';
import HangoutPage from './pages/HangoutPage'; // Main App Pages
import ReelsPage from './pages/ReelsPage'; import HomePage from './pages/HomePage';
import CameraPage from './pages/CameraPage'; import RecipientsPage from './pages/RecipientsPage';
import ChatsPage from './pages/ChatsPage'; import RecipientFormPage from './pages/RecipientFormPage';
import ChatThreadPage from './pages/ChatThreadPage'; import RecipientDetailPage from './pages/RecipientDetailPage';
import SupportFlowPage from './pages/SupportFlowPage';
import OrderDetailPage from './pages/OrderDetailPage';
import VoucherDetailPage from './pages/VoucherDetailPage';
import ActivityPage from './pages/ActivityPage';
import NotificationsPage from './pages/NotificationsPage';
import ProfilePage from './pages/ProfilePage'; import ProfilePage from './pages/ProfilePage';
import EditProfilePage from './pages/EditProfilePage'; import EditProfilePage from './pages/EditProfilePage';
import NotificationsPage from './pages/NotificationsPage';
import PremiumPage from './pages/PremiumPage';
/* Contexts & Components */ const SetupProfileRoute: React.FC = () => {
import { AuthProvider } from './contexts/AuthContext'; const { user, profileStatus } = useAuth();
import ProtectedRoute from './components/ProtectedRoute'; return (
import { isPreviewMode } from './utils/previewMode'; <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 App: React.FC = () => ( const SendSupportFab: React.FC = () => {
<IonApp> const history = useHistory();
<AuthProvider> const location = useLocation();
<IonReactRouter> const hiddenPaths = [
<IonRouterOutlet> '/auth',
{/* Auth Stack */} '/verify-email',
<Route path="/auth" component={AuthPage} exact /> '/forgot-password',
<Route path="/verify-email" component={VerifyEmailPage} exact /> '/verify-reset',
<Route path="/forgot-password" component={ForgotPasswordPage} exact /> '/setup-profile',
<Route path="/new-password" component={NewPasswordPage} exact /> '/support/new',
<ProtectedRoute ];
path="/onboarding/profile" const showOnPaths = ['/home', '/recipients', '/activity', '/profile'];
component={OnboardingProfilePage} const isVisibleRoute =
exact showOnPaths.includes(location.pathname) ||
/> /^\/recipients\/[0-9a-fA-F-]{36}$/.test(location.pathname);
{/* Detailed Stack (No Tabs) */} if (!isVisibleRoute || hiddenPaths.includes(location.pathname)) return null;
<ProtectedRoute path="/reels/:placeId" component={ReelsPage} exact />
<ProtectedRoute
path="/chat/:conversationId"
component={ChatThreadPage}
exact
/>
<ProtectedRoute
path="/profile/edit"
component={EditProfilePage}
exact
/>
<ProtectedRoute path="/premium" component={PremiumPage} exact />
<ProtectedRoute
path="/notifications"
component={NotificationsPage}
exact
/>
<ProtectedRoute path="/camera" component={CameraPage} exact />
{/* Main Tabs */} return (
<Route <button
path={['/icebreaker', '/hangout', '/chats', '/profile']} type="button"
render={() => ( className="app-send-support-fab"
<IonTabs> aria-label="Send support"
<IonRouterOutlet> onClick={() => history.push('/support/new')}
<ProtectedRoute >
path="/icebreaker" <IonIcon icon={heart} />
component={IcebreakerPage} </button>
exact );
/> };
<ProtectedRoute
path="/hangout"
component={HangoutPage}
exact
/>
<ProtectedRoute path="/chats" component={ChatsPage} exact />
<ProtectedRoute
path="/profile"
component={ProfilePage}
exact
/>
</IonRouterOutlet>
<IonTabBar slot="bottom">
<IonTabButton tab="icebreaker" href="/icebreaker">
<IonIcon icon={flameOutline} />
<IonLabel>Icebreaker</IonLabel>
</IonTabButton>
<IonTabButton tab="hangout" href="/hangout">
<IonIcon icon={locationOutline} />
<IonLabel>Hangout</IonLabel>
</IonTabButton>
<IonTabButton tab="chats" href="/chats">
<IonIcon icon={chatbubblesOutline} />
<IonLabel>Chats</IonLabel>
</IonTabButton>
<IonTabButton tab="profile" href="/profile">
<IonIcon icon={personOutline} />
<IonLabel>Profile</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
)}
/>
<Route const PushNotificationHandler: React.FC = () => {
exact const { user, profile } = useAuth();
path="/" const history = useHistory();
render={() => (
<Redirect to={isPreviewMode() ? '/icebreaker' : '/auth'} /> useEffect(() => {
)} if (!Capacitor.isNativePlatform() || !user || !profile) return;
/>
</IonRouterOutlet> const setupPush = async () => {
</IonReactRouter> const handleNotificationTap = (notification: PushNotificationSchema) => {
</AuthProvider> const { orderId, voucherId, screen } = notification.data || {};
</IonApp> 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.

After

Width:  |  Height:  |  Size: 118 KiB

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

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

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

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

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

Before

Width:  |  Height:  |  Size: 148 KiB

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

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

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

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 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;
-70
View File
@@ -1,70 +0,0 @@
import React from 'react';
import { getStorageUrl, getInitials, getInitialsColor } from '../utils/storage';
interface AvatarProps {
src?: string | null;
name?: string | null;
size?: number;
bucket?: string;
className?: string;
style?: React.CSSProperties;
}
const Avatar: React.FC<AvatarProps> = ({
src,
name,
size = 48,
bucket = 'avatars',
className = '',
style = {},
}) => {
const url =
src?.startsWith('/src/assets/') || src?.startsWith('/assets/')
? src
: getStorageUrl(bucket, src);
const containerStyle: React.CSSProperties = {
width: size,
height: size,
borderRadius: '50%',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
background: url ? 'transparent' : getInitialsColor(name || null),
...style,
};
if (url) {
return (
<div className={className} style={containerStyle}>
<img
src={url}
alt={name || 'Avatar'}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
);
}
return (
<div className={className} style={containerStyle}>
<span
style={{
color: '#ffffff',
fontSize: size * 0.4,
fontWeight: 700,
letterSpacing: '-0.02em',
}}
>
{getInitials(name || null)}
</span>
</div>
);
};
export default Avatar;
+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;
+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;
-84
View File
@@ -1,84 +0,0 @@
import React from 'react';
import { IonButton, IonIcon } from '@ionic/react';
interface EmptyStateProps {
icon: string;
title: string;
message: string;
ctaText?: string;
onCtaClick?: () => void;
style?: React.CSSProperties;
}
const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
message,
ctaText,
onCtaClick,
style = {},
}) => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 20px',
textAlign: 'center',
minHeight: '300px',
...style,
}}
>
<div
style={{
width: '80px',
height: '80px',
borderRadius: '24px',
background: 'var(--color-surface-raised)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '20px',
}}
>
<IonIcon
icon={icon}
style={{ fontSize: '40px', color: 'var(--ion-color-primary)' }}
/>
</div>
<h3
style={{
fontSize: '20px',
fontWeight: 700,
margin: '0 0 8px',
color: 'var(--color-text-primary)',
}}
>
{title}
</h3>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: '0 0 24px',
lineHeight: 1.5,
}}
>
{message}
</p>
{ctaText && onCtaClick && (
<IonButton
expand="block"
onClick={onCtaClick}
style={{ '--border-radius': 'var(--radius-pill)', minWidth: '200px' }}
>
{ctaText}
</IonButton>
)}
</div>
);
};
export default EmptyState;
+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;
-97
View File
@@ -1,97 +0,0 @@
import React, { useRef, useEffect } from 'react';
interface OtpInputProps {
value: string;
onChange: (value: string) => void;
length?: number;
disabled?: boolean;
}
const OtpInput: React.FC<OtpInputProps> = ({
value,
onChange,
length = 6,
disabled = false,
}) => {
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement>,
index: number
) => {
const val = e.target.value;
if (!val) return;
const newOtp = value.split('');
newOtp[index] = val.slice(-1);
const combined = newOtp.join('');
onChange(combined);
if (val && index < length - 1) {
inputRefs.current[index + 1]?.focus();
}
};
const handleKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>,
index: number
) => {
if (e.key === 'Backspace' && !value[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const pastedData = e.clipboardData.getData('text').slice(0, length);
if (/^\d+$/.test(pastedData)) {
onChange(pastedData);
inputRefs.current[Math.min(pastedData.length, length - 1)]?.focus();
}
};
return (
<div
style={{
display: 'flex',
gap: '8px',
justifyContent: 'center',
margin: '24px 0',
}}
>
{Array.from({ length }).map((_, i) => (
<input
key={i}
ref={(el) => {
inputRefs.current[i] = el;
}}
type="text"
inputMode="numeric"
pattern="\d*"
maxLength={1}
value={value[i] || ''}
onChange={(e) => handleChange(e, i)}
onKeyDown={(e) => handleKeyDown(e, i)}
onPaste={handlePaste}
disabled={disabled}
style={{
width: '44px',
height: '52px',
textAlign: 'center',
fontSize: '22px',
fontWeight: 700,
borderRadius: '12px',
border: '2px solid transparent',
background: 'var(--color-surface)',
color: 'var(--color-text-primary)',
outline: 'none',
transition: 'border-color 0.2s',
...(value[i] ? { borderColor: 'var(--ion-color-primary)' } : {}),
}}
/>
))}
</div>
);
};
export default OtpInput;
+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;
+25 -12
View File
@@ -1,25 +1,38 @@
import React from 'react'; import React from 'react';
import { Route, Redirect, RouteProps } from 'react-router-dom'; import { Route, Redirect } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { isPreviewMode } from '../utils/previewMode'; import SplashPage from '../pages/SplashPage';
interface ProtectedRouteProps extends RouteProps { interface Props {
component: React.ComponentType<any>; component: React.ComponentType<any>;
path: string | string[];
exact?: boolean;
} }
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ const BYPASS_AUTH = false;
component: Component,
...rest const ProtectedRouteLoader: React.FC = () => (
}) => { <SplashPage title="Send support to loved ones from anywhere" />
const { user } = useAuth(); );
const previewMode = isPreviewMode();
const ProtectedRoute: React.FC<Props> = ({ component: Component, ...rest }) => {
const { user, profile, profileStatus } = useAuth();
return ( return (
<Route <Route
{...rest} {...rest}
render={(props) => render={(props) => {
user || previewMode ? <Component {...props} /> : <Redirect to="/auth" /> if (!user) {
} return <Redirect to="/auth" />;
}
if (profileStatus === 'loading') {
return <ProtectedRouteLoader />;
}
if (profileStatus === 'missing') {
return <Redirect to="/setup-profile" />;
}
return <Component {...props} />;
}}
/> />
); );
}; };
+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;
-68
View File
@@ -1,68 +0,0 @@
import React from 'react';
import { IonSearchbar, IonButton, IonIcon } from '@ionic/react';
import { optionsOutline } from 'ionicons/icons';
interface SearchRowProps {
value: string;
onChange: (e: any) => void;
onFilterClick?: () => void;
placeholder?: string;
}
const SearchRow: React.FC<SearchRowProps> = ({
value,
onChange,
onFilterClick,
placeholder = 'Search...',
}) => {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
margin: '0 20px 14px',
}}
>
<IonSearchbar
value={value}
onIonInput={onChange}
placeholder={placeholder}
style={{
padding: 0,
'--border-radius': 'var(--radius-pill)',
'--background': 'rgba(255,255,255,0.06)',
'--color': 'var(--color-text-primary)',
'--placeholder-color': 'rgba(255,255,255,0.45)',
'--icon-color': 'rgba(255,255,255,0.75)',
'--clear-button-color': 'rgba(255,255,255,0.55)',
}}
/>
{onFilterClick && (
<IonButton
onClick={onFilterClick}
style={{
'--background': 'rgba(255,255,255,0.06)',
'--color': 'var(--color-text-primary)',
'--border-radius': '12px',
width: '44px',
height: '44px',
margin: 0,
flexShrink: 0,
'--padding-start': 0,
'--padding-end': 0,
'--box-shadow': 'none',
}}
>
<IonIcon
icon={optionsOutline}
slot="icon-only"
style={{ fontSize: '20px' }}
/>
</IonButton>
)}
</div>
);
};
export default SearchRow;
+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;
+279 -73
View File
@@ -1,107 +1,313 @@
import React, { createContext, useContext, useEffect, useState } from 'react'; import React, {
import { User, Session } from '@supabase/supabase-js'; 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 { supabase } from '../supabase';
import { Database } from '../database.types'; import {
import { IonSpinner } from '@ionic/react'; buildCacheKey,
readCache,
writeCache,
clearUserCache,
} from '../utils/localCache';
type Profile = Database['public']['Tables']['profiles']['Row']; 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 { interface AuthContextType {
user: User | null; user: User | null;
session: Session | null;
profile: Profile | null; profile: Profile | null;
loading: boolean; profileStatus: ProfileStatus;
refreshProfile: () => Promise<void>; refreshProfile: () => Promise<Profile | null>;
setProfile: (profile: Profile | null) => void;
signOut: () => Promise<void>; signOut: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); 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 }> = ({ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
children, children,
}) => { }) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null); const [profile, setProfileState] = useState<Profile | null>(null);
const [profile, setProfile] = useState<Profile | null>(null); const [profileStatus, setProfileStatus] = useState<ProfileStatus>(
const [loading, setLoading] = useState(true); 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 refreshProfile = async (userId?: string) => { const applyProfile = useCallback((nextProfile: Profile | null) => {
const id = userId || user?.id; if (!mountedRef.current) return;
if (!id) return; setProfileState(nextProfile);
setProfileStatus(nextProfile ? 'loaded' : 'missing');
}, []);
const { data, error } = await supabase const setProfile = useCallback(
.from('profiles') (nextProfile: Profile | null) => {
.select('*') activeProfileFetchRef.current += 1;
.eq('id', id) applyProfile(nextProfile);
.single(); },
[applyProfile]
);
if (data) { const fetchProfile = useCallback(
setProfile(data); async (
} else if (error && error.code !== 'PGRST116') { userId: string,
console.error('Error fetching profile:', error); 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(() => { useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => { mountedRef.current = true;
setSession(session);
setUser(session?.user ?? null); if (BYPASS_AUTH) {
if (session?.user) { setInitializing(false);
refreshProfile(session.user.id).finally(() => setLoading(false)); setProfileStatus('idle');
} else { return () => {
setLoading(false); 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 { const {
data: { subscription }, data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => { } = supabase.auth.onAuthStateChange(async (_event, session) => {
setSession(session); const nextUser = session?.user ?? null;
setUser(session?.user ?? null); if (!mountedRef.current) return;
if (session?.user) { const previousUserId = currentUserIdRef.current;
refreshProfile(session.user.id); currentUserIdRef.current = nextUser?.id ?? null;
if (nextUser) {
if (nextUser.id !== previousUserId) {
setProfileState(null);
setProfileStatus('loading');
}
setUser(nextUser);
await fetchProfile(nextUser.id, { showLoading: false });
} else { } else {
setProfile(null); activeProfileFetchRef.current += 1;
setUser(null);
setProfileState(null);
setProfileStatus('idle');
if (previousUserId) {
await clearUserCache(previousUserId);
}
} }
setLoading(false);
}); });
return () => subscription.unsubscribe(); 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 signOut = async () => { const refreshProfile = useCallback(() => {
await supabase.auth.signOut(); return user
}; ? fetchProfile(user.id, { showLoading: false })
: Promise.resolve(null);
}, [fetchProfile, user]);
if (loading) { const contextValue = useMemo(
return ( () => ({
<div user,
style={{ profile,
display: 'flex', profileStatus,
alignItems: 'center', refreshProfile,
justifyContent: 'center', setProfile,
height: '100vh', signOut,
background: 'var(--color-bg)', }),
}} [profile, profileStatus, refreshProfile, setProfile, signOut, user]
> );
<IonSpinner name="crescent" color="primary" />
</div> if (initializing) {
); return <SplashPage title="Send support to loved ones from anywhere" />;
} }
return ( return (
<AuthContext.Provider <AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>
value={{ user, session, profile, loading, refreshProfile, signOut }}
>
{children}
</AuthContext.Provider>
); );
}; };
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
+403 -473
View File
File diff suppressed because it is too large Load Diff
+12 -10
View File
@@ -56,16 +56,18 @@ if (import.meta.hot) {
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
} }
+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;
+465 -348
View File
@@ -1,33 +1,127 @@
import React, { useState } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import SplashPage from './SplashPage';
import { import {
IonPage, IonPage,
IonContent, IonContent,
IonInput,
IonItem,
IonButton,
IonSpinner,
useIonViewWillEnter, useIonViewWillEnter,
IonRouterLink, IonIcon,
} from '@ionic/react'; } from '@ionic/react';
import { useHistory } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import { mail, lockClosed, logoFacebook } from 'ionicons/icons';
import { IonIcon } from '@ionic/react';
import { enablePreviewMode } from '../utils/previewMode';
import { supabase } from '../supabase'; import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar'; import { useAuth } from '../contexts/AuthContext';
import { Capacitor } from '@capacitor/core'; import { Capacitor } from '@capacitor/core';
import { FirebaseAuthentication } from '@capacitor-firebase/authentication'; 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 AuthPage: React.FC = () => {
const history = useHistory(); const history = useHistory();
const [mode, setMode] = useState<'signin' | 'signup'>('signin'); 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 [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); 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(() => { useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark); setTab('login');
setError(null);
}); });
const showError = (msg: string) => { const showError = (msg: string) => {
@@ -35,83 +129,260 @@ const AuthPage: React.FC = () => {
setTimeout(() => setError(null), 4000); setTimeout(() => setError(null), 4000);
}; };
const handleAuth = async (e: React.FormEvent) => { const getRecipientAvatarUrl = async (
e.preventDefault(); photoPath?: string | null,
setLoading(true); 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 { try {
if (mode === 'signin') { const snapshot = await fetchHomeSnapshot(userId, getRecipientAvatarUrl);
const { error } = await supabase.auth.signInWithPassword({ const hydratedSnapshot = await hydrateHomeSnapshotImages(
email, userId,
password, snapshot
}); );
if (error) { await preloadImageUrls([
if (error.message.toLowerCase().includes('email not confirmed')) { ...hydratedSnapshot.imageUrls,
await supabase.auth.resend({ type: 'signup', email }); ...hydratedSnapshot.lovedOnes
history.push('/verify-email', { email, type: 'signup' }); .map((person) => person.avatar)
return; .filter(Boolean),
} ...hydratedSnapshot.activities
throw error; .map((activity) => activity.avatar)
} .filter(Boolean),
history.push('/icebreaker'); ]);
} else { const warmedSnapshot = setWarmedHomeSnapshot(userId, hydratedSnapshot);
const { data, error } = await supabase.auth.signUp({ const cacheKey = buildCacheKey(userId, 'homeSnapshot');
email, await writeCache(cacheKey, warmedSnapshot);
password, return true;
options: { } catch (err) {
data: { onboarding_complete: false }, console.error('[prefetch] failed', err);
}, return false;
}); }
if (error) throw error; };
if (data.session) { const startPostLoginSplash = () => {
history.push('/onboarding/profile'); signInStartedAtRef.current = Date.now();
} else { splashMinCompleteRef.current = false;
history.push('/verify-email', { email, type: 'signup' }); 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) { } catch (err: any) {
showError(err.message); stopPostLoginSplash();
} finally { showError(err.message || 'Google sign in failed');
setLoading(false); setLoading(false);
} }
}; };
const enterPreviewMode = () => { const handleAppleSignIn = async () => {
enablePreviewMode();
history.push('/icebreaker');
};
const handleSocialSignIn = async (provider: 'google' | 'apple') => {
if (!Capacitor.isNativePlatform()) { if (!Capacitor.isNativePlatform()) {
showError( showError(
`${provider === 'google' ? 'Google' : 'Apple'} Sign-In is only available on native devices.` 'Apple Sign-In is only available in the native app. Use email to sign in here.'
); );
return; return;
} }
setLoading(true);
try { try {
let result; setLoading(true);
if (provider === 'google') { const result = await FirebaseAuthentication.signInWithApple({
result = await FirebaseAuthentication.signInWithGoogle(); skipNativeAuth: true,
} else {
result = await FirebaseAuthentication.signInWithApple({
skipNativeAuth: true,
});
}
const { error } = await supabase.auth.signInWithIdToken({
provider,
token: result.credential!.idToken!,
nonce: result.credential?.nonce,
}); });
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; if (error) throw error;
history.push('/icebreaker');
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) { } catch (err: any) {
showError(err.message); stopPostLoginSplash();
} finally { showError(err.message || 'Apple sign in failed');
setLoading(false); setLoading(false);
} }
}; };
@@ -119,304 +390,150 @@ const AuthPage: React.FC = () => {
return ( return (
<IonPage> <IonPage>
<IonContent <IonContent
className="auth-content"
fullscreen fullscreen
className="ion-padding"
style={{ style={{
'--background': 'var(--color-bg)', '--background':
display: 'flex', 'linear-gradient(180deg, #fafafa 0%, #f6f1ff 52%, #f4f0ff 100%)',
flexDirection: 'column', '--padding-start': '0px',
paddingTop: 'calc(var(--ion-safe-area-top) + 40px)', '--padding-end': '0px',
'--padding-top': '0px',
'--padding-bottom': '0px',
}} }}
> >
<div style={{ padding: '0 20px' }}> <div className="auth-shell auth-shell--centered">
<div style={{ marginBottom: '44px', paddingTop: '24px' }}> <div className="auth-brand-block">
<h1 <div className="auth-brand-header">
style={{ <span className="auth-brand-name">Kumusha</span>
fontSize: '28px', </div>
fontWeight: 800, <h1 className="auth-heading">Care for home</h1>
color: 'var(--color-text-primary)', <p className="auth-subtitle">
margin: '0 0 42px', Trusted support for family back home.
}}
>
{mode === 'signin' ? 'Hi there, 👋' : 'Create account'}
</h1>
<h2
style={{
fontSize: '24px',
fontWeight: 700,
color: 'var(--color-text-primary)',
margin: '0 0 12px',
}}
>
{mode === 'signin'
? 'Sign in to continue'
: 'Sign up to continue'}
</h2>
<p
style={{
fontSize: '16px',
color: 'var(--color-text-secondary)',
margin: 0,
lineHeight: 1.35,
}}
>
{mode === 'signin'
? "Don't have an account? Enter your email and password or choose one of the options below."
: 'Enter your email and password to start discovering people and places nearby.'}
</p> </p>
</div> </div>
<div <div className="auth-card auth-card--elevated">
style={{ <div
display: 'flex', className="auth-mode-toggle"
background: 'var(--color-surface-raised)', role="tablist"
padding: '4px', aria-label="Authentication mode"
borderRadius: 'var(--radius-pill)', >
marginBottom: '20px',
}}
>
{(['signin', 'signup'] as const).map((m) => (
<button <button
key={m} type="button"
onClick={() => setMode(m)} className={`auth-toggle-pill ${tab === 'login' ? 'active' : 'inactive'}`}
style={{ onClick={() => handleTabChange('login')}
flex: 1, disabled={loading}
padding: '10px',
borderRadius: 'var(--radius-pill)',
border: 'none',
fontSize: '14px',
fontWeight: 700,
transition: '0.2s',
background:
mode === m ? 'var(--ion-color-primary)' : 'transparent',
color: mode === m ? '#ffffff' : 'var(--color-text-primary)',
}}
> >
{m === 'signin' ? 'Sign In' : 'Sign Up'} Sign In
</button> </button>
))} <button
</div> type="button"
className={`auth-toggle-pill ${tab === 'register' ? 'active' : 'inactive'}`}
<form onClick={() => handleTabChange('register')}
onSubmit={handleAuth} disabled={loading}
style={{
background: 'transparent',
padding: '0',
borderRadius: '0',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-input)',
'--border-radius': 'var(--radius-control)',
'--border-width': '1px',
'--border-style': 'solid',
'--border-color': 'var(--color-text-secondary)',
'--min-height': '48px',
}}
>
<IonIcon
icon={mail}
slot="start"
style={{ color: 'var(--ion-color-primary)', fontSize: '20px' }}
/>
<IonInput
label="Email"
labelPlacement="floating"
type="email"
value={email}
onIonInput={(e) => setEmail(e.detail.value!)}
required
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-input)',
'--border-radius': 'var(--radius-control)',
'--border-width': '1px',
'--border-style': 'solid',
'--border-color': 'var(--color-text-secondary)',
'--min-height': '48px',
}}
>
<IonIcon
icon={lockClosed}
slot="start"
style={{ color: 'var(--ion-color-primary)', fontSize: '20px' }}
/>
<IonInput
label="Password"
labelPlacement="floating"
type="password"
value={password}
onIonInput={(e) => setPassword(e.detail.value!)}
required
/>
</IonItem>
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
}}
> >
{error} Sign Up
</div> </button>
)} </div>
<IonButton <form
type="submit" className="auth-form-stack"
expand="block" onSubmit={tab === 'login' ? handleSignIn : handleSignUp}
disabled={loading}
style={{
'--border-radius': 'var(--radius-pill)',
'--color': '#000000',
fontWeight: 800,
height: '48px',
margin: '8px 28px 0',
}}
> >
{loading ? ( <AuthFormFields
<IonSpinner name="crescent" /> mode={tab}
) : mode === 'signin' ? ( email={email}
'Sign in' password={password}
) : ( confirmPassword={confirmPassword}
'Continue' 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>
)} )}
</IonButton>
{mode === 'signin' && ( {error ? (
<IonRouterLink <div className="auth-inline-message">{error}</div>
routerLink="/forgot-password" ) : null}
style={{
textAlign: 'center', {tab === 'login' && (
fontSize: '13px', <div className="auth-meta-row">
color: 'var(--ion-color-primary)', <span className="auth-meta-hint">Secure email sign in</span>
marginTop: '8px', <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)
}
> >
Forgot password? {loading
</IonRouterLink> ? 'Please wait...'
)} : tab === 'login'
</form> ? 'Sign In'
: 'Create Account'}
</button>
</form>
<div <div className="auth-divider">
style={{ <span>Or continue with</span>
textAlign: 'center', </div>
margin: '56px 0 26px',
fontSize: '13px',
color: 'var(--color-text-tertiary)',
}}
>
OR
</div>
<div <div className="auth-social-buttons">
style={{ <SocialAuthButton
display: 'flex', provider="google"
flexDirection: 'column', label="Google"
gap: '16px', onClick={handleGoogleSignIn}
padding: '0 28px', disabled={loading}
}}
>
<IonButton
expand="block"
onClick={() => handleSocialSignIn('apple')}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
style={{ marginRight: '10px' }}
>
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
Sign in with Apple
</IonButton>
<IonButton
expand="block"
onClick={() =>
showError(
'Facebook Sign-In is only available on native devices.'
)
}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<IonIcon
icon={logoFacebook}
style={{ marginRight: '10px', fontSize: '22px' }}
/> />
Sign in with Facebook <SocialAuthButton
</IonButton> provider="apple"
<IonButton label="Apple"
expand="block" onClick={handleAppleSignIn}
onClick={() => handleSocialSignIn('google')} disabled={loading}
style={{ />
flex: 1, </div>
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<span
style={{
fontSize: '24px',
fontWeight: 900,
marginRight: '12px',
}}
>
G
</span>
Sign in with Google
</IonButton>
<IonButton
expand="block"
onClick={enterPreviewMode}
style={{
flex: 1,
'--background': 'var(--ion-color-primary)',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
Preview with dummy data
</IonButton>
</div> </div>
</div> </div>
{showPostLoginSplash && (
<div className="auth-splash-overlay">
<SplashPage title="Send support to loved ones from anywhere" />
</div>
)}
</IonContent> </IonContent>
</IonPage> </IonPage>
); );
-311
View File
@@ -1,311 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonIcon,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonItem,
IonTextarea,
IonSelect,
IonSelectOption,
} from '@ionic/react';
import {
closeOutline,
cameraOutline,
imageOutline,
sendOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const CameraPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [caption, setCaption] = useState('');
const [placeId, setPlaceId] = useState<string | null>(null);
const [places, setPlaces] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [isPublishOpen, setIsPublishOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchPlaces();
});
const fetchPlaces = async () => {
const { data } = await supabase.from('places').select('id, name');
setPlaces(data || []);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewUrl(URL.createObjectURL(selectedFile));
setIsPublishOpen(true);
}
};
const handlePublish = async () => {
if (!file || !user || !placeId) {
setError('Please select a place and add a file.');
return;
}
setUploading(true);
try {
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
const filePath = `${fileName}`;
// 1. Upload to storage
const { error: uploadError } = await supabase.storage
.from('reels')
.upload(filePath, file);
if (uploadError) throw uploadError;
// 2. Create reel record
const { error: reelError } = await supabase.from('reels').insert({
author_id: user.id,
place_id: placeId,
caption,
media_path: filePath,
media_type: file.type.startsWith('video') ? 'video' : 'image',
});
if (reelError) throw reelError;
history.replace(`/reels/${placeId}`);
} catch (err: any) {
setError(err.message);
} finally {
setUploading(false);
}
};
return (
<IonPage>
<IonContent fullscreen style={{ '--background': '#000000' }}>
<div
style={{
position: 'absolute',
top: 'calc(14px + var(--ion-safe-area-top))',
left: '20px',
zIndex: 10,
}}
>
<IonButton
fill="clear"
onClick={() => history.goBack()}
style={{
'--background': 'rgba(0,0,0,0.3)',
'--color': '#ffffff',
'--border-radius': '50%',
width: '40px',
height: '40px',
margin: 0,
}}
>
<IonIcon icon={closeOutline} slot="icon-only" />
</IonButton>
</div>
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
}}
>
<div
style={{
width: '120px',
height: '120px',
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '24px',
}}
>
<IonIcon
icon={cameraOutline}
style={{ fontSize: '48px', opacity: 0.5 }}
/>
</div>
<h2
style={{ fontSize: '20px', fontWeight: 800, marginBottom: '8px' }}
>
Create a Reel
</h2>
<p
style={{
opacity: 0.6,
fontSize: '14px',
marginBottom: '32px',
textAlign: 'center',
padding: '0 40px',
}}
>
Share your experience at a local spot. Select a photo or video to
begin.
</p>
<label
style={{
background: 'var(--ion-color-primary)',
color: '#ffffff',
padding: '14px 32px',
borderRadius: 'var(--radius-pill)',
fontWeight: 700,
fontSize: '15px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<IonIcon icon={imageOutline} style={{ fontSize: '20px' }} />
Select Media
<input
type="file"
accept="image/*,video/*"
onChange={handleFileChange}
hidden
/>
</label>
</div>
{/* Publish Modal */}
<IonModal
isOpen={isPublishOpen}
onDidDismiss={() => {
setIsPublishOpen(false);
setPreviewUrl(null);
setFile(null);
}}
>
<IonHeader>
<IonToolbar>
<IonTitle>New Reel</IonTitle>
<IonButtons slot="start">
<IonButton onClick={() => setIsPublishOpen(false)}>
Cancel
</IonButton>
</IonButtons>
<IonButtons slot="end">
<IonButton
onClick={handlePublish}
disabled={uploading || !placeId}
>
{uploading ? <IonSpinner name="crescent" /> : 'Share'}
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
<div
style={{
width: '100px',
height: '140px',
borderRadius: '12px',
overflow: 'hidden',
background: '#000',
flexShrink: 0,
}}
>
{file?.type.startsWith('video') ? (
<video
src={previewUrl || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<img
src={previewUrl || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
alt="Preview"
/>
)}
</div>
<IonTextarea
placeholder="Write a caption..."
value={caption}
onIonInput={(e) => setCaption(e.detail.value!)}
rows={6}
style={{
'--background': 'var(--color-bg)',
'--padding-start': '12px',
'--border-radius': '12px',
}}
/>
</div>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Tag a place"
labelPlacement="floating"
placeholder="Select place"
value={placeId}
onIonChange={(e) => setPlaceId(e.detail.value)}
>
{places.map((p) => (
<IonSelectOption key={p.id} value={p.id}>
{p.name}
</IonSelectOption>
))}
</IonSelect>
</IonItem>
{error && (
<div
style={{
color: 'var(--ion-color-danger)',
fontSize: '13px',
marginTop: '12px',
padding: '0 4px',
}}
>
{error}
</div>
)}
</IonContent>
</IonModal>
</IonContent>
</IonPage>
);
};
export default CameraPage;
-263
View File
@@ -1,263 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonInput,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { chevronBackOutline, sendOutline } from 'ionicons/icons';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import Avatar from '../components/Avatar';
import { previewChats, previewMessages } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const ChatThreadPage: React.FC = () => {
const history = useHistory();
const { conversationId } = useParams<{ conversationId: string }>();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [messages, setMessages] = useState<any[]>([]);
const [otherUser, setOtherUser] = useState<any>(null);
const [text, setText] = useState('');
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLIonContentElement>(null);
const fetchThread = async () => {
setLoading(true);
if (previewMode) {
const chat =
previewChats.find((item) => item.id === conversationId) ||
previewChats[0];
setOtherUser(chat.otherUser);
setMessages(previewMessages[chat.id] || []);
setLoading(false);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
return;
}
if (!user) return;
try {
// 1. Get other participant
const { data: conv } = await supabase
.from('conversations')
.select(
`
match_id,
matches(
user_a:profiles!matches_user_a_fkey(id, full_name, avatar_path),
user_b:profiles!matches_user_b_fkey(id, full_name, avatar_path)
)
`
)
.eq('id', conversationId)
.single();
if (conv) {
const match = (conv as any).matches;
setOtherUser(match.user_a.id === user.id ? match.user_b : match.user_a);
}
// 2. Get messages
const { data: msgs } = await supabase
.from('messages')
.select('*')
.eq('conversation_id', conversationId)
.order('created_at', { ascending: true });
setMessages(msgs || []);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
} catch (err: any) {
console.error('Fetch thread error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchThread();
});
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim() || (!user && !previewMode) || sending) return;
setSending(true);
const msgBody = text.trim();
setText('');
if (previewMode) {
const previewMessage = {
id: `preview-message-${Date.now()}`,
sender_id: 'preview-me',
body: msgBody,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, previewMessage]);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
setSending(false);
return;
}
try {
const { data, error } = await supabase
.from('messages')
.insert({
conversation_id: conversationId,
sender_id: user.id,
body: msgBody,
})
.select()
.single();
if (error) throw error;
setMessages((prev) => [...prev, data]);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
// Invoke notification
supabase.functions.invoke('send-message-notification', {
body: { conversationId, messageId: data.id },
});
} catch (err: any) {
console.error('Send error:', err);
} finally {
setSending(false);
}
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
<IonButtons slot="start">
<IonButton
onClick={() => history.goBack()}
style={{ '--color': 'var(--color-text-primary)' }}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle>
{otherUser && (
<div
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
<Avatar
src={otherUser.avatar_path}
name={otherUser.full_name}
size={32}
/>
<span style={{ fontSize: '16px', fontWeight: 700 }}>
{otherUser.full_name}
</span>
</div>
)}
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent
ref={scrollRef}
style={{ '--background': 'var(--color-bg)' }}
className="ion-padding"
>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
paddingBottom: '20px',
}}
>
{messages.map((msg) => {
const isMine = previewMode
? msg.sender_id === 'preview-me'
: msg.sender_id === user?.id;
return (
<div
key={msg.id}
style={{
alignSelf: isMine ? 'flex-end' : 'flex-start',
maxWidth: '78%',
background: isMine
? 'var(--ion-color-primary)'
: 'var(--color-surface)',
color: isMine ? '#ffffff' : 'var(--color-text-primary)',
padding: '10px 14px',
borderRadius: isMine
? '18px 18px 4px 18px'
: '18px 18px 18px 4px',
fontSize: '14px',
lineHeight: 1.4,
boxShadow: '0 2px 8px rgba(76, 5, 25, 0.02)',
}}
>
{msg.body}
</div>
);
})}
</div>
)}
</IonContent>
<div
style={{
background: '#ffffff',
padding: '10px 20px calc(10px + var(--ion-safe-area-bottom))',
borderTop: '1px solid var(--color-border)',
}}
>
<form
onSubmit={handleSend}
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
>
<IonInput
value={text}
onIonInput={(e) => setText(e.detail.value!)}
placeholder="Type a message..."
style={{
background: 'var(--color-bg)',
'--border-radius': 'var(--radius-pill)',
'--padding-start': '16px',
fontSize: '14px',
}}
/>
<IonButton
type="submit"
disabled={!text.trim() || sending}
style={{
'--border-radius': '50%',
width: '44px',
height: '44px',
margin: 0,
flexShrink: 0,
}}
>
{sending ? (
<IonSpinner name="crescent" />
) : (
<IonIcon icon={sendOutline} slot="icon-only" />
)}
</IonButton>
</form>
</div>
</IonPage>
);
};
export default ChatThreadPage;
-218
View File
@@ -1,218 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonSpinner,
useIonViewWillEnter,
IonRefresher,
IonRefresherContent,
IonIcon,
} from '@ionic/react';
import { chatbubbleEllipsesOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { formatTime } from '../utils/format';
import SearchRow from '../components/SearchRow';
import Avatar from '../components/Avatar';
import EmptyState from '../components/EmptyState';
import { previewChats } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const ChatsPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [chats, setChats] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const fetchChats = async () => {
if (previewMode) {
setChats(previewChats);
setLoading(false);
return;
}
if (!user) return;
setLoading(true);
try {
const { data, error } = await supabase
.from('matches')
.select(
`
id,
last_message_at,
user_a:profiles!matches_user_a_fkey(id, full_name, avatar_path),
user_b:profiles!matches_user_b_fkey(id, full_name, avatar_path),
conversations(
id,
messages(
body,
created_at
)
)
`
)
.or(`user_a.eq.${user.id},user_b.eq.${user.id}`)
.order('last_message_at', { ascending: false });
if (error) throw error;
const formatted =
data?.map((m) => {
const otherUser = m.user_a.id === user.id ? m.user_b : m.user_a;
const conversation = m.conversations?.[0];
const lastMsg = conversation?.messages?.[0]; // ordered by handle-swipe or trigger later
return {
id: conversation?.id,
otherUser,
lastMessage: lastMsg?.body || 'New match! Start chatting.',
timestamp: m.last_message_at || lastMsg?.created_at,
};
}) || [];
setChats(formatted);
} catch (err: any) {
console.error('Fetch chats error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchChats();
});
const handleRefresh = async (event: CustomEvent) => {
await fetchChats();
event.detail.complete();
};
const displayChats = previewMode ? previewChats : chats;
const filteredChats = displayChats.filter(
(c) =>
c.otherUser.full_name.toLowerCase().includes(query.toLowerCase()) ||
c.lastMessage.toLowerCase().includes(query.toLowerCase())
);
return (
<IonPage>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div
style={{ padding: 'calc(16px + var(--ion-safe-area-top)) 20px 12px' }}
>
<h1
style={{
fontSize: '24px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: 0,
}}
>
Chats
</h1>
</div>
<SearchRow
value={query}
onChange={(e) => setQuery(e.detail.value!)}
placeholder="Search conversations"
/>
<div
style={{ paddingBottom: 'calc(92px + var(--ion-safe-area-bottom))' }}
>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : filteredChats.length === 0 ? (
<EmptyState
icon={chatbubbleEllipsesOutline}
title="No chats yet"
message="Matches you message will appear here. Start swiping to find someone!"
ctaText="Find people"
onCtaClick={() => history.push('/icebreaker')}
/>
) : (
filteredChats.map((chat) => (
<div
key={chat.id}
onClick={() => history.push(`/chat/${chat.id}`)}
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
margin: '0 20px 12px',
padding: '16px',
display: 'flex',
alignItems: 'center',
gap: '12px',
boxShadow: 'var(--shadow-card)',
}}
>
<Avatar
src={chat.otherUser.image || chat.otherUser.avatar_path}
name={chat.otherUser.full_name}
size={56}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '4px',
}}
>
<h3
style={{
fontSize: '16px',
fontWeight: 800,
margin: 0,
color: 'var(--color-text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{chat.otherUser.full_name}
</h3>
<span
style={{
fontSize: '11px',
color: 'var(--color-text-tertiary)',
fontWeight: 600,
}}
>
{formatTime(chat.timestamp)}
</span>
</div>
<p
style={{
fontSize: '14px',
color: 'var(--color-text-secondary)',
margin: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{chat.lastMessage}
</p>
</div>
</div>
))
)}
</div>
</IonContent>
</IonPage>
);
};
export default ChatsPage;
+291 -325
View File
@@ -1,375 +1,341 @@
import React, { useState, useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
IonPage, IonButton,
IonButtons,
IonContent, IonContent,
IonHeader, IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon, IonIcon,
IonItem,
IonInput, IonInput,
IonTextarea, IonPage,
IonSelect, IonTitle,
IonSelectOption, IonToolbar,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react'; } from '@ionic/react';
import { import { chevronBackOutline } from 'ionicons/icons';
chevronBackOutline,
cameraOutline,
trashOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase'; import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar'; import AvatarPicker from '../components/AvatarPicker';
import { getStorageUrl } from '../utils/storage'; 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 EditProfilePage: React.FC = () => {
const history = useHistory(); const history = useHistory();
const { user, profile, refreshProfile } = useAuth(); const { user, refreshProfile } = useAuth();
const [fullName, setFullName] = useState(profile?.full_name || ''); const [firstName, setFirstName] = useState('');
const [bio, setBio] = useState(profile?.bio || ''); const [lastName, setLastName] = useState('');
const [gender, setGender] = useState(profile?.gender || ''); const [phone, setPhone] = useState('');
const [relationshipGoal, setRelationshipGoal] = useState( const [country, setCountry] = useState('');
profile?.relationship_goal || '' const [avatarPath, setAvatarPath] = useState<string | null>(null);
); const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [photos, setPhotos] = useState<any[]>([]); const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchPhotos = async () => { useEffect(() => {
if (!user) return; void loadProfile();
const { data } = await supabase }, [user?.id]);
.from('profile_photos')
.select('*') const showError = (message: string) => {
.eq('user_id', user.id) setError(message);
.order('position'); setTimeout(() => setError(null), 4000);
setPhotos(data || []);
}; };
useIonViewWillEnter(() => { const loadProfile = async () => {
setStatusBarStyle(Style.Dark);
if (profile) {
setFullName(profile.full_name);
setBio(profile.bio || '');
setGender(profile.gender || '');
setRelationshipGoal(profile.relationship_goal || '');
}
fetchPhotos();
});
const handleSave = async () => {
if (!user) return;
setLoading(true); setLoading(true);
try {
const { error } = await supabase
.from('profiles')
.update({
full_name: fullName,
bio,
gender,
relationship_goal: relationshipGoal,
})
.eq('id', user.id);
if (error) throw error; if (!user) {
await refreshProfile(); const preview = getPreviewProfilePayload();
history.goBack(); setFirstName(preview.first_name);
} catch (err: any) { setLastName(preview.last_name);
console.error('Save error:', err); setPhone(preview.phone);
} finally { setCountry(preview.country_of_residence);
setAvatarPath(null);
setAvatarPreview(preview.avatar_url);
setLoading(false); 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 handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = event.target.files?.[0];
if (!file || !user) return; if (!file) return;
setUploading(true); const reader = new FileReader();
try { reader.onload = () => {
const fileExt = file.name.split('.').pop(); setAvatarFile(file);
const fileName = `${user.id}-${Date.now()}.${fileExt}`; setAvatarPreview(
const filePath = `profile-photos/${fileName}`; typeof reader.result === 'string' ? reader.result : null
);
};
reader.onerror = () => {
showError(
'We could not preview that image. Please choose another photo.'
);
};
reader.readAsDataURL(file);
};
const { error: uploadError } = await supabase.storage const handleSubmit = async (event: React.FormEvent) => {
.from('profile-photos') event.preventDefault();
.upload(filePath, file); const cleanFirstName = firstName.trim();
const cleanLastName = lastName.trim();
const cleanPhone = phone.trim();
const cleanCountry = country.trim();
if (uploadError) throw uploadError; if (!cleanFirstName || !cleanLastName || !cleanPhone || !cleanCountry) {
showError('Please complete all fields');
return;
}
const { error: dbError } = await supabase.from('profile_photos').insert({ if (!user) {
user_id: user.id, setSaving(true);
storage_path: filePath, savePreviewProfilePayload({
position: photos.length, ...getPreviewProfilePayload(),
first_name: cleanFirstName,
last_name: cleanLastName,
phone: cleanPhone,
country_of_residence: cleanCountry,
avatar_url: avatarPreview,
}); });
setSaving(false);
if (dbError) throw dbError; history.replace('/profile');
fetchPhotos(); return;
} catch (err: any) {
console.error('Photo upload error:', err);
} finally {
setUploading(false);
} }
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 deletePhoto = async (id: string, path: string) => { const initials = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
try {
await supabase.from('profile_photos').delete().eq('id', id);
await supabase.storage.from('profile-photos').remove([path]);
fetchPhotos();
} catch (err) {
console.error('Delete photo error:', err);
}
};
const goals = [
{ label: 'Long-term partner', value: 'long_term' },
{ label: 'Long-term, but short-term OK', value: 'long_term_short_ok' },
{ label: 'Short-term, but long-term OK', value: 'short_term_long_ok' },
{ label: 'Short-term fun', value: 'short_term' },
{ label: 'New friends', value: 'friends' },
{ label: 'Still figuring it out', value: 'figuring_it_out' },
];
return ( return (
<IonPage> <IonPage style={{ backgroundColor: '#fafafa' }}>
<IonHeader className="ion-no-border"> <IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': 'var(--color-bg)' }}> <IonToolbar
style={
{
'--background': '#fafafa',
'--border-width': '0px',
} as React.CSSProperties
}
>
<IonButtons slot="start"> <IonButtons slot="start">
<IonButton <IonButton
onClick={() => history.goBack()} className="profile-back-button"
style={{ '--color': 'var(--color-text-primary)' }} fill="clear"
onClick={handleGoBack}
aria-label="Go back"
style={
{
'--profile-action-accent': '#6d28d9',
} as React.CSSProperties
}
> >
<IonIcon icon={chevronBackOutline} slot="icon-only" /> <IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton> </IonButton>
</IonButtons> </IonButtons>
<IonTitle>Edit Profile</IonTitle> <IonTitle style={{ fontSize: 18, fontWeight: 700 }}>
<IonButtons slot="end"> Edit profile
<IonButton </IonTitle>
onClick={handleSave}
disabled={loading}
style={{ fontWeight: 700 }}
>
{loading ? <IonSpinner name="crescent" /> : 'Save'}
</IonButton>
</IonButtons>
</IonToolbar> </IonToolbar>
</IonHeader> </IonHeader>
<IonContent <IonContent
className="ion-padding" className="edit-profile-shell"
style={{ '--background': 'var(--color-bg)' }} style={
{
'--background': '#fafafa',
'--padding-top': '8px',
} as React.CSSProperties
}
> >
{/* Photo Manager */} <form onSubmit={handleSubmit}>
<div style={{ marginBottom: '24px' }}> <div className="edit-profile-card">
<h3 <AvatarPicker
style={{ previewUrl={avatarPreview}
fontSize: '13px', onFileChange={handleAvatarChange}
fontWeight: 800, initials={initials || undefined}
color: 'var(--color-text-tertiary)', disabled={loading || saving}
textTransform: 'uppercase', />
marginBottom: '12px', <IonInput
}} className="epc-field"
> type="text"
Profile Photos label="First name"
</h3> labelPlacement="floating"
<div placeholder="e.g. Tadiwa"
style={{ value={firstName}
display: 'grid', disabled={loading || saving}
gridTemplateColumns: 'repeat(3, 1fr)', onIonInput={(event) => setFirstName(event.detail.value ?? '')}
gap: '10px', />
}} <IonInput
> className="epc-field"
{photos.map((photo) => ( type="text"
<div label="Last name"
key={photo.id} labelPlacement="floating"
style={{ placeholder="e.g. Moyo"
paddingBottom: '125%', value={lastName}
background: 'var(--color-surface-raised)', disabled={loading || saving}
borderRadius: '12px', onIonInput={(event) => setLastName(event.detail.value ?? '')}
position: 'relative', />
overflow: 'hidden', <IonInput
}} className="epc-field"
> type="tel"
<img inputMode="tel"
src={ label="Mobile number"
getStorageUrl('profile-photos', photo.storage_path) || '' labelPlacement="floating"
} placeholder="e.g. +44 7123 456789"
style={{ value={phone}
position: 'absolute', disabled={loading || saving}
top: 0, onIonInput={(event) => setPhone(event.detail.value ?? '')}
left: 0, />
width: '100%', <IonInput
height: '100%', className="epc-field"
objectFit: 'cover', type="text"
}} label="Country of residence"
alt="Profile" labelPlacement="floating"
/> placeholder="e.g. United Kingdom"
<button value={country}
onClick={() => deletePhoto(photo.id, photo.storage_path)} disabled={loading || saving}
style={{ onIonInput={(event) => setCountry(event.detail.value ?? '')}
position: 'absolute', />
top: '8px', {error && (
right: '8px', <p style={{ margin: 0, color: '#dc2626', fontSize: 13 }}>
background: 'rgba(0,0,0,0.5)', {error}
border: 'none', </p>
borderRadius: '50%',
width: '24px',
height: '24px',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon icon={trashOutline} style={{ fontSize: '14px' }} />
</button>
</div>
))}
{photos.length < 6 && (
<label
style={{
paddingBottom: '125%',
background: 'var(--color-surface)',
borderRadius: '12px',
position: 'relative',
border: '2px dashed var(--ion-color-primary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{uploading ? (
<IonSpinner name="crescent" />
) : (
<IonIcon
icon={cameraOutline}
style={{
fontSize: '24px',
color: 'var(--ion-color-primary)',
}}
/>
)}
</div>
<input
type="file"
accept="image/*"
onChange={handlePhotoUpload}
hidden
disabled={uploading}
/>
</label>
)} )}
</div> </div>
</div>
{/* Basic Info */} <IonButton
<div type="submit"
style={{ expand="block"
background: 'var(--color-surface)', className="epc-save-btn"
borderRadius: 'var(--radius-card)', disabled={loading || saving}
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '16px',
boxShadow: 'var(--shadow-card)',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
> >
<IonInput {saving ? 'Saving...' : 'Save profile'}
label="Full Name" </IonButton>
labelPlacement="floating" </form>
value={fullName}
onIonInput={(e) => setFullName(e.detail.value!)}
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Gender"
labelPlacement="floating"
value={gender}
onIonChange={(e) => setGender(e.detail.value)}
interface="action-sheet"
>
<IonSelectOption value="woman">Woman</IonSelectOption>
<IonSelectOption value="man">Man</IonSelectOption>
<IonSelectOption value="nonbinary">Non-binary</IonSelectOption>
<IonSelectOption value="other">Other</IonSelectOption>
<IonSelectOption value="prefer_not_to_say">
Prefer not to say
</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Relationship Goal"
labelPlacement="floating"
value={relationshipGoal}
onIonChange={(e) => setRelationshipGoal(e.detail.value)}
interface="action-sheet"
>
{goals.map((g) => (
<IonSelectOption key={g.value} value={g.value}>
{g.label}
</IonSelectOption>
))}
</IonSelect>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonTextarea
label="Bio"
labelPlacement="floating"
value={bio}
onIonInput={(e) => setBio(e.detail.value!)}
placeholder="Tell us about yourself..."
rows={4}
/>
</IonItem>
</div>
</IonContent> </IonContent>
</IonPage> </IonPage>
); );
+166 -112
View File
@@ -1,149 +1,203 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
IonPage,
IonContent,
IonInput,
IonItem,
IonButton, IonButton,
IonSpinner, IonButtons,
useIonViewWillEnter, IonContent,
IonHeader,
IonIcon,
IonInput,
IonPage,
IonTitle,
IonToolbar,
} from '@ionic/react'; } from '@ionic/react';
import { chevronBackOutline, keyOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase'; import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar'; import '../styles/auth.css';
const ForgotPasswordPage: React.FC = () => { const ForgotPasswordPage: React.FC = () => {
const history = useHistory(); const history = useHistory();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
useIonViewWillEnter(() => { const showMessage = (message: string, kind: 'error' | 'status') => {
setStatusBarStyle(Style.Dark); if (kind === 'error') {
}); setError(message);
setStatus(null);
const showError = (msg: string) => { setTimeout(() => setError(null), 4000);
setError(msg); } else {
setTimeout(() => setError(null), 4000); setStatus(message);
}; setError(null);
setTimeout(() => setStatus(null), 4000);
const handleReset = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const { error } = await supabase.auth.resetPasswordForEmail(email);
if (error) throw error;
history.push('/verify-email', { email, type: 'recovery' });
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
} }
}; };
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 ( return (
<IonPage> <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 <IonContent
fullscreen className="auth-content"
style={{ style={
'--background': 'var(--color-bg)', {
}} '--background': 'linear-gradient(180deg, #fafafa 0%, #f4f0ff 100%)',
'--padding-start': '0px',
'--padding-end': '0px',
'--padding-top': '0px',
'--padding-bottom': '0px',
} as React.CSSProperties
}
> >
<div style={{ padding: 'calc(var(--ion-safe-area-top) + 40px) 20px' }}> <div className="auth-shell">
<div style={{ marginBottom: '32px' }}> <div
<h1 className="auth-intro-block"
style={{ style={{ alignItems: 'flex-start', textAlign: 'left' }}
fontSize: '28px', >
fontWeight: 800, <div className="auth-icon-container">
color: 'var(--color-text-primary)', <IonIcon icon={keyOutline} />
margin: '0 0 8px', </div>
}} <div>
> <h1 className="auth-intro-heading">Forgot your password?</h1>
Reset Password <p className="auth-intro-body" style={{ marginTop: '8px' }}>
</h1> Enter your account email and we'll send you a 6-digit reset
<p code.
style={{ </p>
fontSize: '15px', </div>
color: 'var(--color-text-secondary)',
margin: 0,
lineHeight: 1.5,
}}
>
Enter your email and we'll send you a code to reset your password.
</p>
</div> </div>
<form <form
onSubmit={handleReset} onSubmit={handleSubmit}
style={{ className="auth-form-card"
background: 'var(--color-surface)', style={{ gap: '16px' }}
padding: '24px',
borderRadius: 'var(--radius-card)',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
> >
<IonItem <IonInput
lines="none" type="email"
style={{ label="Email address"
'--background': 'var(--color-bg)', labelPlacement="floating"
'--border-radius': 'var(--radius-control)', value={email}
}} onIonInput={(event) => setEmail(event.detail.value ?? '')}
> disabled={loading}
<IonInput placeholder="you@example.com"
label="Email" style={
labelPlacement="floating" {
type="email" '--background': '#fafafa',
value={email} '--border-radius': '12px',
onIonInput={(e) => setEmail(e.detail.value!)} '--padding-start': '14px',
required '--padding-end': '14px',
/> '--highlight-color-focused': '#6d28d9',
</IonItem> } as React.CSSProperties
}
/>
{error && ( {error && (
<div <p className="auth-status-text" style={{ color: '#dc2626' }}>
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
}}
>
{error} {error}
</div> </p>
)}
{status && (
<p className="auth-status-text" style={{ color: '#16a34a' }}>
{status}
</p>
)} )}
<IonButton <IonButton
type="submit" type="submit"
expand="block" expand="block"
disabled={loading} disabled={loading || !email.trim()}
style={{ style={
'--border-radius': 'var(--radius-pill)', {
height: '48px', '--background': '#6d28d9',
marginTop: '8px', '--background-activated': '#5b21b6',
}} '--border-radius': '999px',
'--box-shadow': 'none',
'--color': '#ffffff',
height: '52px',
fontSize: '15px',
fontWeight: 700,
marginTop: '4px',
} as React.CSSProperties
}
> >
{loading ? <IonSpinner name="crescent" /> : 'Send Code'} {loading ? 'Sending code...' : 'Send reset code'}
</IonButton> </IonButton>
<button
type="button"
onClick={() => history.goBack()}
style={{
background: 'none',
border: 'none',
color: 'var(--color-text-tertiary)',
fontSize: '14px',
fontWeight: 600,
marginTop: '12px',
}}
>
Back to Sign In
</button>
</form> </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> </div>
</IonContent> </IonContent>
</IonPage> </IonPage>
-558
View File
@@ -1,558 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonIcon,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonRefresher,
IonRefresherContent,
} from '@ionic/react';
import {
locationOutline,
notificationsOutline,
star,
cameraOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { formatDistance } from '../utils/format';
import { getStorageUrl } from '../utils/storage';
import SearchRow from '../components/SearchRow';
import EmptyState from '../components/EmptyState';
import { previewPlaces } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const HangoutPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [places, setPlaces] = useState<any[]>([]);
const [reelStories, setReelStories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const [sort, setSort] = useState<'most_busy' | 'nearest' | 'top_rated'>(
'most_busy'
);
const fetchPlaces = async () => {
setLoading(true);
if (previewMode) {
setPlaces(previewPlaces);
setReelStories(previewPlaces);
setLoading(false);
return;
}
try {
// Phase 1: simple local query. Phase 2: use places-nearby Edge Function
const { data, error } = await supabase
.from('places')
.select(
`
*,
reels(id)
`
)
.order('rating', { ascending: false });
if (error) throw error;
setPlaces(data || []);
// Derive stories (places with reels)
const stories =
data?.filter((p) => p.reels?.length > 0).slice(0, 10) || [];
setReelStories(stories);
} catch (err: any) {
console.error('Fetch places error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchPlaces();
});
const handleRefresh = async (event: CustomEvent) => {
await fetchPlaces();
event.detail.complete();
};
const displayPlaces = previewMode ? previewPlaces : places;
const displayStories = previewMode ? previewPlaces : reelStories;
const filteredPlaces = displayPlaces.filter(
(p) =>
p.name.toLowerCase().includes(query.toLowerCase()) ||
p.category.toLowerCase().includes(query.toLowerCase())
);
return (
<IonPage>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div
style={{
padding: 'calc(14px + var(--ion-safe-area-top)) 20px 8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '12px',
background: 'rgba(255,255,255,0.06)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={locationOutline}
style={{ color: 'var(--ion-color-primary)', fontSize: '18px' }}
/>
</div>
<div
style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}
>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--color-text-tertiary)',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}
>
Nearby now
</span>
<span
style={{
fontSize: '17px',
fontWeight: 700,
color: 'var(--color-text-primary)',
lineHeight: 1.1,
}}
>
Midrand, ZA
</span>
</div>
</div>
<IonButton
fill="clear"
onClick={() => history.push('/notifications')}
style={{
'--background': 'rgba(255,255,255,0.06)',
'--color': 'var(--color-text-primary)',
'--border-radius': '12px',
width: '44px',
height: '44px',
margin: 0,
'--padding-start': 0,
'--padding-end': 0,
}}
>
<IonIcon
icon={notificationsOutline}
slot="icon-only"
style={{ fontSize: '20px' }}
/>
</IonButton>
</div>
<SearchRow
value={query}
onChange={(e) => setQuery(e.detail.value!)}
placeholder="Search place of interest"
/>
<div
style={{
display: 'flex',
gap: '10px',
padding: '2px 20px 26px',
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}
>
{(['most_busy', 'nearest', 'top_rated'] as const).map((s) => {
const active = sort === s;
return (
<button
key={s}
onClick={() => setSort(s)}
style={{
padding: '10px 16px',
borderRadius: 'var(--radius-pill)',
border: 'none',
fontSize: '14px',
fontWeight: active ? 700 : 600,
whiteSpace: 'nowrap',
background: active
? 'linear-gradient(180deg, #f24f88 0%, var(--ion-color-primary) 100%)'
: 'rgba(255,255,255,0.05)',
color: active ? '#ffffff' : 'rgba(255,255,255,0.82)',
boxShadow: active
? '0 10px 24px rgba(254, 60, 114, 0.26)'
: 'none',
transition: '0.2s ease',
flexShrink: 0,
}}
>
{s.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
</button>
);
})}
</div>
{/* Reels Stories Section */}
<div style={{ marginBottom: '26px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0 20px 14px',
}}
>
<h2
style={{
fontSize: '18px',
fontWeight: 800,
margin: 0,
color: 'var(--color-text-primary)',
letterSpacing: '-0.02em',
}}
>
Reels
</h2>
<button
onClick={() => history.push('/camera')}
style={{
width: '44px',
height: '44px',
borderRadius: '12px',
border: 'none',
background:
'linear-gradient(180deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.06) 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
flexShrink: 0,
}}
>
<IonIcon
icon={cameraOutline}
style={{ fontSize: '20px', color: 'var(--color-text-primary)' }}
/>
</button>
</div>
<div
style={{
display: 'flex',
gap: '14px',
padding: '0 20px',
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}
>
<div style={{ flexShrink: 0, width: '76px', textAlign: 'center' }}>
<div
onClick={() => history.push('/camera')}
style={{
width: '68px',
height: '68px',
borderRadius: '50%',
padding: '2px',
background:
'linear-gradient(180deg, #f24f88 0%, #a60b4a 100%)',
marginBottom: '8px',
}}
>
<div
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
background: '#08080a',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon
icon={cameraOutline}
style={{
fontSize: '22px',
color: 'var(--ion-color-primary)',
}}
/>
</div>
</div>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--color-text-primary)',
opacity: 0.95,
}}
>
My reels
</span>
</div>
{displayStories.map((story) => (
<div
key={story.id}
onClick={() => history.push(`/reels/${story.id}`)}
style={{ flexShrink: 0, width: '76px', textAlign: 'center' }}
>
<div
style={{
width: '68px',
height: '68px',
borderRadius: '50%',
padding: '2px',
background:
'linear-gradient(180deg, #f24f88 0%, #a60b4a 100%)',
marginBottom: '8px',
}}
>
<div
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
padding: '3px',
background: '#08080a',
}}
>
<img
src={
story.image ||
getStorageUrl('place-media', story.image_path) ||
''
}
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
objectFit: 'cover',
}}
alt={story.name}
/>
</div>
</div>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--color-text-primary)',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
opacity: 0.95,
}}
>
{story.name}
</span>
</div>
))}
</div>
</div>
{/* Places List */}
<div
style={{ paddingBottom: 'calc(92px + var(--ion-safe-area-bottom))' }}
>
<h2
style={{
fontSize: '18px',
fontWeight: 800,
margin: '0 20px 16px',
color: 'var(--color-text-primary)',
}}
>
Places of interest 📍
</h2>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : filteredPlaces.length === 0 ? (
<EmptyState
icon={locationOutline}
title="No places found"
message="We couldn't find any places matching your search nearby."
ctaText="Refresh"
onCtaClick={fetchPlaces}
/>
) : (
filteredPlaces.map((place, index) => (
<div
key={place.id}
onClick={() => history.push(`/reels/${place.id}`)}
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
margin: '0 20px 12px',
padding: '12px',
display: 'flex',
gap: '12px',
boxShadow: 'var(--shadow-card)',
}}
>
<div
style={{
flexShrink: 0,
width: '112px',
height: '88px',
borderRadius: 'var(--radius-control)',
overflow: 'hidden',
}}
>
<img
src={
place.image ||
getStorageUrl('place-media', place.image_path) ||
''
}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
alt={place.name}
/>
</div>
<div style={{ flex: 1, position: 'relative' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<h3
style={{
fontSize: '16px',
fontWeight: 800,
margin: '0 0 4px',
color: 'var(--color-text-primary)',
}}
>
{index + 1}. {place.name}
</h3>
</div>
<p
style={{
fontSize: '12px',
color: 'var(--color-text-secondary)',
margin: '0 0 4px',
}}
>
{place.category}
</p>
<p
style={{
fontSize: '12px',
color: 'var(--color-text-tertiary)',
margin: '0 0 8px',
}}
>
{place.distance || '0.9 mi'}
</p>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<div style={{ display: 'flex', gap: '1px' }}>
{[1, 2, 3, 4, 5].map((s) => (
<IonIcon
key={s}
icon={star}
style={{
fontSize: '12px',
color:
s <= place.rating
? 'var(--ion-color-primary)'
: 'var(--color-border)',
}}
/>
))}
</div>
<span
style={{
fontSize: '11px',
color: 'var(--color-text-tertiary)',
fontWeight: 600,
}}
>
{place.review_count > 1000
? `${(place.review_count / 1000).toFixed(1)}k`
: place.review_count}{' '}
Reviews
</span>
</div>
{place.busyness === 'busy' && (
<div
style={{
position: 'absolute',
top: 0,
right: 0,
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<span
style={{
fontSize: '10px',
color: 'var(--color-text-tertiary)',
fontWeight: 600,
}}
>
Busy
</span>
<div
style={{
background: 'var(--ion-color-primary)',
color: '#ffffff',
fontSize: '9px',
fontWeight: 800,
padding: '2px 6px',
borderRadius: 'var(--radius-pill)',
}}
>
LIVE
</div>
</div>
)}
</div>
</div>
))
)}
</div>
</IonContent>
</IonPage>
);
};
export default HangoutPage;
+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;
-699
View File
@@ -1,699 +0,0 @@
import React, { useRef, useState } from 'react';
import {
IonButton,
IonContent,
IonIcon,
IonPage,
useIonViewWillEnter,
} from '@ionic/react';
import {
apps,
close,
heart,
optionsOutline,
paperPlane,
refreshOutline,
walk,
} from 'ionicons/icons';
import Lottie from 'lottie-react';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { getStorageUrl } from '../utils/storage';
import { previewProfiles } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
import heartsAnimation from '../assets/images/hearts.json';
type SwipeAction = 'like' | 'skip';
type DragState = {
x: number;
y: number;
startX: number;
startY: number;
active: boolean;
};
const SWIPE_THRESHOLD = 105;
const EXIT_DISTANCE = 620;
const calculateAge = (birthDate?: string | null, fallback?: number) => {
if (fallback) return fallback;
if (!birthDate) return null;
const birth = new Date(birthDate);
if (Number.isNaN(birth.getTime())) return null;
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age -= 1;
}
return age;
};
const IcebreakerPage: React.FC = () => {
const { user } = useAuth();
const previewMode = isPreviewMode();
const cardRef = useRef<HTMLDivElement | null>(null);
const [profiles, setProfiles] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
const [swiping, setSwiping] = useState(false);
const [showHearts, setShowHearts] = useState(false);
const [drag, setDrag] = useState<DragState | null>(null);
const [exitDirection, setExitDirection] = useState<SwipeAction | null>(null);
const fetchProfiles = async () => {
if (previewMode) {
setProfiles(previewProfiles);
setCurrentIndex(0);
setLoading(false);
return;
}
if (!user) {
setLoading(false);
return;
}
setLoading(true);
try {
const { data: swipedData } = await supabase
.from('swipes')
.select('swiped_id')
.eq('swiper_id', user.id);
const swipedIds = swipedData?.map((s) => s.swiped_id) || [];
const excludedIds = [user.id, ...swipedIds].filter(Boolean);
let query = supabase
.from('profiles')
.select('*, profile_photos(storage_path, position)')
.neq('id', user.id)
.limit(20);
if (excludedIds.length > 0) {
query = query.not('id', 'in', `(${excludedIds.join(',')})`);
}
const { data, error } = await query;
if (error) throw error;
setProfiles(data || []);
setCurrentIndex(0);
} catch (err) {
console.error('Profile fetch error:', err);
setProfiles([]);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchProfiles();
});
const displayProfiles = previewMode ? previewProfiles : profiles;
const currentProfile = displayProfiles.length
? displayProfiles[currentIndex % displayProfiles.length]
: null;
const nextProfile =
displayProfiles.length > 1
? displayProfiles[(currentIndex + 1) % displayProfiles.length]
: null;
const getProfileImages = (profile: any) => {
if (!profile) return [];
if (Array.isArray(profile.images) && profile.images.length > 0) {
return profile.images;
}
if (
Array.isArray(profile.profile_photos) &&
profile.profile_photos.length > 0
) {
return profile.profile_photos
.slice()
.sort((a: any, b: any) => (a.position || 0) - (b.position || 0))
.map((photo: any) =>
getStorageUrl('profile-photos', photo.storage_path)
)
.filter(Boolean);
}
return [
profile.image ||
profile.avatar ||
getStorageUrl('avatars', profile.avatar_path) ||
'',
].filter(Boolean);
};
const getProfileImage = (profile: any) => getProfileImages(profile)[0] || '';
const commitSwipe = async (action: SwipeAction) => {
if (displayProfiles.length === 0) return;
if (previewMode) {
setCurrentIndex((prev) => (prev + 1) % displayProfiles.length);
return;
}
const targetUserId = profiles[currentIndex]?.id;
if (!targetUserId) return;
try {
const { data, error } = await supabase.functions.invoke('handle-swipe', {
body: { targetUserId, action },
});
if (error) throw error;
if (data?.matched && action === 'like') {
setShowHearts(true);
setTimeout(() => setShowHearts(false), 1250);
}
setCurrentIndex((prev) => prev + 1);
} catch (err) {
console.error('Swipe error:', err);
}
};
const animateSwipe = (action: SwipeAction) => {
if (swiping || !currentProfile) return;
setSwiping(true);
setExitDirection(action);
setDrag((prev) => ({
x: action === 'like' ? EXIT_DISTANCE : -EXIT_DISTANCE,
y: prev?.y || -12,
startX: prev?.startX || 0,
startY: prev?.startY || 0,
active: false,
}));
if (action === 'like') {
setShowHearts(true);
setTimeout(() => setShowHearts(false), 1250);
}
window.setTimeout(async () => {
await commitSwipe(action);
setDrag(null);
setExitDirection(null);
setSwiping(false);
}, 280);
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (swiping || !currentProfile) return;
cardRef.current?.setPointerCapture(event.pointerId);
setDrag({
x: 0,
y: 0,
startX: event.clientX,
startY: event.clientY,
active: true,
});
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!drag?.active || swiping) return;
setDrag({
...drag,
x: event.clientX - drag.startX,
y: event.clientY - drag.startY,
});
};
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (!drag || swiping) return;
cardRef.current?.releasePointerCapture(event.pointerId);
const absX = Math.abs(drag.x);
const absY = Math.abs(drag.y);
if (absX < 8 && absY < 8) {
const isRightHalf = event.clientX > window.innerWidth / 2;
setDrag(null);
animateSwipe(isRightHalf ? 'like' : 'skip');
return;
}
if (drag.x > SWIPE_THRESHOLD) {
animateSwipe('like');
return;
}
if (drag.x < -SWIPE_THRESHOLD) {
animateSwipe('skip');
return;
}
setDrag({ ...drag, x: 0, y: 0, active: false });
window.setTimeout(() => setDrag(null), 180);
};
const dragX = drag?.x || 0;
const dragY = drag?.y || 0;
const rotate = Math.max(-12, Math.min(12, dragX / 22));
const transition = drag?.active
? 'none'
: 'transform 280ms cubic-bezier(.2,.9,.22,1), opacity 220ms ease';
const topTransform = `translate3d(${dragX}px, ${dragY}px, 0) rotate(${rotate}deg)`;
const name = currentProfile?.full_name || currentProfile?.name || 'Nearby';
const age = calculateAge(currentProfile?.birth_date, currentProfile?.age);
const distance =
currentProfile?.distance || currentProfile?.travelTime || '3 min';
const interests =
currentProfile?.interests || currentProfile?.userProfile?.interests || [];
const currentImages = getProfileImages(currentProfile);
const currentImage = currentImages[0] || getProfileImage(currentProfile);
const topProgressBars =
currentImages.length > 0 ? currentImages : [currentImage];
return (
<IonPage>
<IonContent
fullscreen
style={{ '--background': '#000000' }}
scrollY={false}
>
<main
style={{
position: 'fixed',
inset: 0,
background: '#000000',
overflow: 'hidden',
touchAction: 'none',
}}
>
{loading && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
fontSize: 15,
}}
>
Finding people nearby
</div>
)}
{!loading && nextProfile && (
<article
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
right: 0,
top: 'calc(var(--ion-safe-area-top, 0px) + 6px)',
bottom: 'calc(76px + var(--ion-safe-area-bottom, 0px))',
borderRadius: 0,
overflow: 'hidden',
background: '#111111',
transform: 'scale(0.975)',
}}
>
<img
src={getProfileImage(nextProfile)}
alt="Next profile"
draggable={false}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
userSelect: 'none',
}}
/>
</article>
)}
{!loading && currentProfile && (
<article
ref={cardRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={{
position: 'absolute',
left: 0,
right: 0,
top: 'calc(var(--ion-safe-area-top, 0px) + 6px)',
bottom: 'calc(76px + var(--ion-safe-area-bottom, 0px))',
borderRadius: 0,
transform: topTransform,
transition,
opacity: exitDirection ? 0.9 : 1,
overflow: 'hidden',
background: '#111111',
cursor: drag?.active ? 'grabbing' : 'grab',
willChange: 'transform, opacity',
}}
>
<img
key={currentProfile.id}
src={currentImage}
alt={name}
draggable={false}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
userSelect: 'none',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
inset: 0,
background:
'linear-gradient(180deg, rgba(0,0,0,0.08) 0%, rgba(0,0,0,0.02) 45%, rgba(0,0,0,0.62) 73%, rgba(0,0,0,0.96) 100%)',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
top: 7,
left: 8,
right: 8,
display: 'grid',
gridTemplateColumns: `repeat(${topProgressBars.length}, 1fr)`,
gap: 4,
zIndex: 4,
}}
>
{topProgressBars.map((_, index) => (
<div
key={`media-progress-${index}`}
style={{
height: 3,
borderRadius: 999,
background:
index === 0
? 'rgba(255,255,255,0.94)'
: 'rgba(30,30,30,0.72)',
}}
/>
))}
</div>
<IonButton
fill="clear"
aria-label="Filter profiles"
style={{
position: 'absolute',
top: 24,
right: 14,
zIndex: 8,
'--color': '#fe3c72',
'--background': 'rgba(28, 28, 28, 0.76)',
'--border-radius': '999px',
width: 48,
height: 48,
}}
>
<IonIcon
icon={optionsOutline}
slot="icon-only"
style={{ fontSize: 27 }}
/>
</IonButton>
<section
style={{
position: 'absolute',
left: 14,
right: 14,
bottom: '34px',
zIndex: 4,
color: '#ffffff',
pointerEvents: 'none',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 14,
marginBottom: 22,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
textShadow: '0 2px 14px rgba(0,0,0,0.75)',
}}
>
<h1
style={{
margin: '0 0 4px',
fontSize: 30,
lineHeight: 1,
fontWeight: 900,
letterSpacing: -0.8,
}}
>
{name}, {age || ''}
</h1>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
color: '#ffffff',
fontSize: 18,
fontWeight: 800,
}}
>
<IonIcon
icon={walk}
style={{
color: '#ffffff',
flexShrink: 0,
fontSize: 25,
}}
/>
<span>{distance}</span>
</div>
</div>
<IonButton
aria-label="Send heart message"
onClick={(event) => {
event.stopPropagation();
animateSwipe('like');
}}
style={{
'--background': '#fe3c72',
'--color': '#111111',
'--border-radius': '999px',
width: 52,
height: 52,
marginTop: 2,
pointerEvents: 'auto',
flexShrink: 0,
}}
>
<IonIcon
icon={paperPlane}
slot="icon-only"
style={{ fontSize: 24 }}
/>
</IonButton>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 10,
fontSize: 21,
fontWeight: 900,
textShadow: '0 2px 12px rgba(0,0,0,0.8)',
}}
>
<IonIcon
icon={apps}
style={{ color: '#ffffff', fontSize: 32, flexShrink: 0 }}
/>
<span>Interests</span>
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
maxWidth: '100%',
}}
>
{interests
.slice(0, 5)
.map((interest: string, index: number) => (
<span
key={interest}
style={{
padding: '7px 13px',
borderRadius: 999,
background:
index === 0 || index === 3
? '#fe3c72'
: 'rgba(44, 53, 57, 0.78)',
color: '#ffffff',
fontSize: 16,
lineHeight: 1,
fontWeight: 650,
backdropFilter: 'blur(12px)',
maxWidth: '100%',
}}
>
{interest}
</span>
))}
</div>
</section>
</article>
)}
{!loading && displayProfiles.length === 0 && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 14,
color: '#ffffff',
padding: 24,
textAlign: 'center',
}}
>
<div style={{ fontSize: 18, fontWeight: 800 }}>
No nearby profiles right now.
</div>
<IonButton
onClick={fetchProfiles}
style={{
'--background': '#fe3c72',
'--border-radius': '999px',
}}
>
<IonIcon icon={refreshOutline} slot="start" />
Refresh
</IonButton>
</div>
)}
{!loading && currentProfile && (
<div
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 'calc(24px + var(--ion-safe-area-bottom, 0px))',
zIndex: 10,
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
padding: '0 54px',
pointerEvents: 'none',
}}
>
<IonButton
aria-label="Skip"
onClick={() => animateSwipe('skip')}
style={{
'--background': 'rgba(45,45,45,0.92)',
'--color': '#ff9f0a',
'--border-radius': '999px',
width: 70,
height: 70,
pointerEvents: 'auto',
}}
>
<IonIcon
icon={close}
slot="icon-only"
style={{ fontSize: 40, fontWeight: 900 }}
/>
</IonButton>
<IonButton
aria-label="Like"
onClick={() => animateSwipe('like')}
style={{
'--background': 'rgba(45,45,45,0.92)',
'--color': '#ff0000',
'--border-radius': '999px',
width: 70,
height: 70,
pointerEvents: 'auto',
}}
>
<IonIcon
icon={heart}
slot="icon-only"
style={{ fontSize: 42 }}
/>
</IonButton>
<IonButton
aria-label="Message"
onClick={() => animateSwipe('like')}
style={{
'--background': 'rgba(45,45,45,0.92)',
'--color': '#1683ff',
'--border-radius': '999px',
width: 70,
height: 70,
pointerEvents: 'auto',
}}
>
<IonIcon
icon={paperPlane}
slot="icon-only"
style={{ fontSize: 38 }}
/>
</IonButton>
</div>
)}
{showHearts && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<Lottie
animationData={heartsAnimation}
loop={false}
style={{ width: 310 }}
/>
</div>
)}
</main>
</IonContent>
</IonPage>
);
};
export default IcebreakerPage;
-165
View File
@@ -1,165 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonInput,
IonItem,
IonButton,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const NewPasswordPage: React.FC = () => {
const history = useHistory();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
showError("Passwords don't match");
return;
}
if (password.length < 6) {
showError('Password must be at least 6 characters');
return;
}
setLoading(true);
try {
const { error } = await supabase.auth.updateUser({ password });
if (error) throw error;
history.push('/icebreaker');
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background': 'var(--color-bg)',
}}
>
<div style={{ padding: 'calc(var(--ion-safe-area-top) + 40px) 20px' }}>
<div style={{ marginBottom: '32px' }}>
<h1
style={{
fontSize: '28px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 8px',
}}
>
Create New Password
</h1>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: 0,
lineHeight: 1.5,
}}
>
Choose a strong password for your InSpot account.
</p>
</div>
<form
onSubmit={handleUpdate}
style={{
background: 'var(--color-surface)',
padding: '24px',
borderRadius: 'var(--radius-card)',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="New Password"
labelPlacement="floating"
type="password"
value={password}
onIonInput={(e) => setPassword(e.detail.value!)}
required
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="Confirm Password"
labelPlacement="floating"
type="password"
value={confirmPassword}
onIonInput={(e) => setConfirmPassword(e.detail.value!)}
required
/>
</IonItem>
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
}}
>
{error}
</div>
)}
<IonButton
type="submit"
expand="block"
disabled={loading}
style={{
'--border-radius': 'var(--radius-pill)',
height: '48px',
marginTop: '8px',
}}
>
{loading ? <IonSpinner name="crescent" /> : 'Update Password'}
</IonButton>
</form>
</div>
</IonContent>
</IonPage>
);
};
export default NewPasswordPage;
+433 -181
View File
@@ -1,239 +1,491 @@
import React, { useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { import {
IonPage, IonActionSheet,
IonButton,
IonButtons,
IonContent, IonContent,
IonHeader, IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon, IonIcon,
IonSpinner, IonPage,
IonSkeletonText,
IonTitle,
IonToolbar,
useIonViewWillEnter, useIonViewWillEnter,
} from '@ionic/react'; } from '@ionic/react';
import { import {
chevronBackOutline, chevronBackOutline,
chevronForwardOutline,
ellipsisHorizontal,
flashOutline,
medkitOutline,
notificationsOutline, notificationsOutline,
heart, phonePortraitOutline,
chatbubble, receiptOutline,
people, trashOutline,
flash,
} from 'ionicons/icons'; } from 'ionicons/icons';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import basketIcon from '../assets/basket.png';
import { supabase } from '../supabase'; import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar'; import '../styles/activity.css';
import { formatTime } from '../utils/format'; import '../styles/recipients.css';
import EmptyState from '../components/EmptyState';
import { previewNotifications } from '../utils/previewData'; type ServiceType =
import { isPreviewMode } from '../utils/previewMode'; | 'grocery'
import Lottie from 'lottie-react'; | 'medication'
import notificationsAnimation from '../assets/images/InSpotNotifications.json'; | '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 NotificationsPage: React.FC = () => {
const history = useHistory(); const history = useHistory();
const { user } = useAuth(); const { user } = useAuth();
const previewMode = isPreviewMode(); const isPreviewMode = !user;
const [notifications, setNotifications] = useState<any[]>([]); const [notifications, setNotifications] = useState<NotificationRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showActions, setShowActions] = useState(false);
const fetchNotifications = async () => { const showError = useCallback((message: string) => {
if (previewMode) { setError(message);
setNotifications(previewNotifications); setTimeout(() => setError(null), 4000);
setLoading(false); }, []);
return;
} const loadNotifications = useCallback(async () => {
if (!user) return;
setLoading(true); setLoading(true);
setError(null);
try { try {
const { data, error } = await supabase const activeUserId = user?.id ?? previewUserId;
let { data, error: loadError } = await supabase
.from('notifications') .from('notifications')
.select('*') .select(notificationSelect)
.eq('user_id', user.id) .eq('user_id', activeUserId)
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
if (error) throw error; if (!loadError && user?.id && (data ?? []).length === 0) {
setNotifications(data || []); const previewResult = await supabase
.from('notifications')
.select(notificationSelect)
.eq('user_id', previewUserId)
.order('created_at', { ascending: false });
// Mark as read after a short delay data = previewResult.data;
if (data && data.some((n) => !n.read_at)) { loadError = previewResult.error;
setTimeout(async () => {
await supabase
.from('notifications')
.update({ read_at: new Date().toISOString() })
.eq('user_id', user.id)
.is('read_at', null);
}, 2000);
} }
} catch (err: any) {
console.error('Fetch notifications error:', err); 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 { } finally {
setLoading(false); setLoading(false);
} }
}; }, [showError, user?.id]);
useEffect(() => {
void loadNotifications();
}, [loadNotifications]);
useIonViewWillEnter(() => { useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark); void loadNotifications();
fetchNotifications();
}); });
const getIcon = (type: string) => { const markRead = async (notificationId: string) => {
switch (type) { const readAt = new Date().toISOString();
case 'reel_like': setNotifications((current) =>
return heart; current.map((item) =>
case 'reel_comment': item.id === notificationId ? { ...item, read_at: readAt } : item
return chatbubble; )
case 'match': );
return people;
case 'message': if (isPreviewMode) {
return chatbubble; const { error: updateError } = await supabase
default: .from('notifications')
return flash; .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 ( return (
<IonPage> <IonPage style={{ backgroundColor: '#fafafa' }}>
<IonHeader className="ion-no-border"> <IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': 'var(--color-bg)' }}> <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"> <IonButtons slot="start">
<IonButton <IonButton
onClick={() => history.goBack()} fill="clear"
style={{ '--color': 'var(--color-text-primary)' }} 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" /> <IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton> </IonButton>
</IonButtons> </IonButtons>
<IonTitle>Notifications</IonTitle> <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> </IonToolbar>
</IonHeader> </IonHeader>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}> <IonContent
{previewMode && ( style={
<div {
style={{ '--background': '#fafafa',
display: 'flex', '--padding-top': '8px',
justifyContent: 'center', } as React.CSSProperties
padding: '12px 20px 0', }
}} >
> <IonActionSheet
<Lottie isOpen={showActions}
animationData={notificationsAnimation} onDidDismiss={() => setShowActions(false)}
loop header="Notifications"
style={{ width: 120 }} cssClass="app-action-sheet"
/> buttons={[
</div> {
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>
)} )}
<div style={{ padding: '16px 20px' }}>
{loading ? ( {loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}> <div className="notifications-list-card">
<IonSpinner name="crescent" color="primary" /> {[1, 2, 3].map((item) => (
</div> <div className="notification-row" key={item}>
) : notifications.length === 0 ? ( <IonSkeletonText
<EmptyState animated
icon={notificationsOutline}
title="No notifications yet"
message="When you get likes, matches, or messages, they'll show up here."
ctaText="Go discover"
onCtaClick={() => history.push('/icebreaker')}
/>
) : (
<div
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
>
{notifications.map((n) => (
<div
key={n.id}
onClick={() => {
if (previewMode && !n.read_at) {
setNotifications((prev) =>
prev.map((item) =>
item.id === n.id
? { ...item, read_at: new Date().toISOString() }
: item
)
);
}
if (n.deep_link) history.push(n.deep_link);
}}
style={{ style={{
background: 'var(--color-surface)', width: 44,
borderRadius: 'var(--radius-card)', height: 44,
padding: '16px', borderRadius: 12,
display: 'flex', flexShrink: 0,
alignItems: 'center', }}
gap: '12px', />
boxShadow: 'var(--shadow-card)', <div style={{ flex: 1 }}>
opacity: n.read_at ? 0.7 : 1, <IonSkeletonText
position: 'relative', 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 <div
style={{ className={`nr-icon-box ${service.className} ${notification.read_at ? 'is-read' : ''}`}
background: 'var(--color-accent-soft)',
width: '44px',
height: '44px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
> >
<IonIcon {service.iconImageSrc ? (
icon={getIcon(n.type)} <img src={service.iconImageSrc} alt="" />
style={{ ) : (
color: 'var(--ion-color-primary)', <IonIcon icon={service.icon} />
fontSize: '20px', )}
}}
/>
</div> </div>
<div style={{ flex: 1 }}> <div className="nr-content">
<div <h3 className="nr-title">{notification.title}</h3>
style={{ <p className="nr-body">{notification.body}</p>
fontSize: '15px', <span className="nr-time">
fontWeight: 700, {new Date(notification.created_at).toLocaleString()}
color: 'var(--color-text-primary)', </span>
marginBottom: '2px',
}}
>
{n.title}
</div>
<div
style={{
fontSize: '13px',
color: 'var(--color-text-secondary)',
lineHeight: 1.4,
}}
>
{n.body}
</div>
<div
style={{
fontSize: '11px',
color: 'var(--color-text-tertiary)',
marginTop: '4px',
fontWeight: 600,
}}
>
{formatTime(n.created_at)}
</div>
</div> </div>
{!n.read_at && ( <IonIcon
<div icon={chevronForwardOutline}
style={{ className="nr-link-icon"
width: '8px', />
height: '8px', </button>
background: 'var(--ion-color-primary)', );
borderRadius: '50%', })}
}} </div>
/> )}
)}
</div>
))}
</div>
)}
</div>
</IonContent> </IonContent>
</IonPage> </IonPage>
); );

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