Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52125c20dc |
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -4,7 +4,7 @@ android {
|
||||
namespace = "io.ionic.starter"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.habitmode.llemba.com"
|
||||
applicationId "com.afmeaglesnzw.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -22,6 +21,12 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="com.afmeaglesnzw.app" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">app</string>
|
||||
<string name="title_activity_main">app</string>
|
||||
<string name="app_name">AFM.EAGLESNZW</string>
|
||||
<string name="title_activity_main">AFM.EAGLESNZW</string>
|
||||
<string name="package_name">io.ionic.starter</string>
|
||||
<string name="custom_url_scheme">io.ionic.starter</string>
|
||||
</resources>
|
||||
|
||||
@@ -15,5 +15,4 @@ ext {
|
||||
cordovaAndroidVersion = '14.0.1'
|
||||
rgcfaIncludeGoogle = true
|
||||
androidxCredentialsVersion = '1.3.0'
|
||||
javaVersion = JavaVersion.VERSION_17
|
||||
}
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.habitmode.llemba.com',
|
||||
appName: 'HabitMode',
|
||||
appId: 'com.afmeaglesnzw.app',
|
||||
appName: 'AFM.EAGLESNZW',
|
||||
webDir: 'dist',
|
||||
plugins: {
|
||||
FirebaseAuthentication: {
|
||||
skipNativeAuth: false,
|
||||
providers: ["google.com", "apple.com"],
|
||||
providers: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,13 +55,6 @@ workflows:
|
||||
if [ -n "$CM_DEBUG_KEYSTORE" ]; then
|
||||
echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12
|
||||
fi
|
||||
- name: Check native credentials
|
||||
script: |
|
||||
if [ -n "$VITE_GOOGLE_MAPS_KEY" ]; then
|
||||
echo "VITE_GOOGLE_MAPS_KEY: set (length=${#VITE_GOOGLE_MAPS_KEY}, ends …${VITE_GOOGLE_MAPS_KEY: -4})"
|
||||
else
|
||||
echo "WARNING: VITE_GOOGLE_MAPS_KEY not set — Google Maps will not render"
|
||||
fi
|
||||
- name: Build debug APK
|
||||
script: |
|
||||
cd android
|
||||
@@ -75,21 +68,6 @@ workflows:
|
||||
else
|
||||
./gradlew assembleDebug --no-daemon
|
||||
fi
|
||||
- name: Verify manifest substitution
|
||||
script: |
|
||||
MANIFEST="android/app/build/intermediates/merged_manifests/debug/AndroidManifest.xml"
|
||||
if [ -f "$MANIFEST" ]; then
|
||||
VAL=$(grep -A1 'geo.API_KEY' "$MANIFEST" | grep -o 'android:value="[^"]*"' | cut -d'"' -f2)
|
||||
if [ -z "$VAL" ]; then
|
||||
echo "WARNING: geo.API_KEY meta-data not found in merged manifest"
|
||||
elif [ "$VAL" = '${googleMapsApiKey}' ]; then
|
||||
echo "WARNING: geo.API_KEY placeholder was not substituted — key missing from build env"
|
||||
else
|
||||
echo "geo.API_KEY substituted OK (ends …${VAL: -4})"
|
||||
fi
|
||||
else
|
||||
echo "Merged manifest not found at $MANIFEST"
|
||||
fi
|
||||
artifacts:
|
||||
- android/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -308,7 +308,7 @@
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.afmeaglesnzw.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -330,7 +330,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.afmeaglesnzw.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -2,11 +2,5 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
<key>aps-environment</key>
|
||||
<string>production</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,7 +1,5 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
import FirebaseCore
|
||||
import FirebaseMessaging
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
@@ -9,40 +7,43 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
FirebaseApp.configure()
|
||||
// Override point for customization after application launch.
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {}
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {}
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {}
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {}
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {}
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
// Called when the app was launched with a url. Feel free to add additional processing here,
|
||||
// but if you want the App API to support tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||
// Called when the app was launched with an activity, including Universal Links.
|
||||
// Feel free to add additional processing here, but if you want the App API to support
|
||||
// tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
Messaging.messaging().apnsToken = deviceToken
|
||||
Messaging.messaging().token { token, error in
|
||||
if let error = error {
|
||||
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
|
||||
} else if let token = token {
|
||||
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>HabitMode</string>
|
||||
<string>AFM.EAGLESNZW</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -50,11 +50,17 @@
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.afmeaglesnzw.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>com.googleusercontent.apps.1097913965629-pf48952i0uv9shgvie3dq11s18sd0t2p</string>
|
||||
<string>com.afmeaglesnzw.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>This app needs access to your photo library.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>This app needs camera access.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Generated
-3404
File diff suppressed because it is too large
Load Diff
+6
-4
@@ -18,12 +18,14 @@
|
||||
"@capacitor/haptics": "8.0.2",
|
||||
"@capacitor/ios": "8.3.4",
|
||||
"@capacitor/keyboard": "8.0.3",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"@capacitor/push-notifications": "^8.1.1",
|
||||
"@capacitor/status-bar": "8.0.2",
|
||||
"@ionic/pwa-elements": "^3.4.0",
|
||||
"@ionic/react": "^8.5.0",
|
||||
"@ionic/react-router": "^8.5.0",
|
||||
"@supabase/supabase-js": "^2.0.0",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@ionic/pwa-elements": "^3.0.0",
|
||||
"@ionic/react": "8.8.7",
|
||||
"@ionic/react-router": "8.8.7",
|
||||
"@supabase/supabase-js": "^2.108.1",
|
||||
"firebase": "^11.0.0",
|
||||
"ionicons": "^7.4.0",
|
||||
"katex": "^0.16.0",
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+140
-14
@@ -1,12 +1,32 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Redirect, Route } from 'react-router-dom';
|
||||
import { IonApp, IonRouterOutlet } from '@ionic/react';
|
||||
import {
|
||||
IonApp,
|
||||
IonIcon,
|
||||
IonLabel,
|
||||
IonRouterOutlet,
|
||||
IonTabBar,
|
||||
IonTabButton,
|
||||
IonTabs,
|
||||
setupIonicReact,
|
||||
} from '@ionic/react';
|
||||
import { IonReactRouter } from '@ionic/react-router';
|
||||
import {
|
||||
homeOutline,
|
||||
calendarOutline,
|
||||
personOutline,
|
||||
newspaperOutline,
|
||||
} from 'ionicons/icons';
|
||||
|
||||
/* Core CSS required for Ionic components to work properly */
|
||||
import '@ionic/react/css/core.css';
|
||||
|
||||
/* Basic CSS for apps built with Ionic */
|
||||
import '@ionic/react/css/normalize.css';
|
||||
import '@ionic/react/css/structure.css';
|
||||
import '@ionic/react/css/typography.css';
|
||||
|
||||
/* Optional CSS utils that can be commented out */
|
||||
import '@ionic/react/css/padding.css';
|
||||
import '@ionic/react/css/float-elements.css';
|
||||
import '@ionic/react/css/text-alignment.css';
|
||||
@@ -14,27 +34,133 @@ import '@ionic/react/css/text-transformation.css';
|
||||
import '@ionic/react/css/flex-utils.css';
|
||||
import '@ionic/react/css/display.css';
|
||||
|
||||
/* Theme variables */
|
||||
import './theme/variables.css';
|
||||
|
||||
import Home from './pages/Home';
|
||||
import Account from './pages/Account';
|
||||
import Auth from './pages/Auth';
|
||||
import Stats from './pages/Stats';
|
||||
import HabitDetail from './pages/HabitDetail';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
|
||||
const App = (): any => (
|
||||
import Auth from './pages/Auth';
|
||||
import VerifyEmail from './pages/VerifyEmail';
|
||||
import SetupProfile from './pages/SetupProfile';
|
||||
import Home from './pages/Home';
|
||||
import Events from './pages/Events';
|
||||
import Profile from './pages/Profile';
|
||||
import EventDetail from './pages/EventDetail';
|
||||
import AdminEventForm from './pages/AdminEventForm';
|
||||
import News from './pages/News';
|
||||
import NewsDetail from './pages/NewsDetail';
|
||||
import AdminNewsForm from './pages/AdminNewsForm';
|
||||
|
||||
import { setStatusBarStyle, Style } from './utils/statusBar';
|
||||
|
||||
setupIonicReact();
|
||||
|
||||
const SetupProfileRoute: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
if (!user) return <Redirect to="/auth" />;
|
||||
return <SetupProfile />;
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
useEffect(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<IonApp>
|
||||
<AuthProvider>
|
||||
<IonReactRouter>
|
||||
<IonRouterOutlet>
|
||||
<Route exact path="/auth" component={Auth} />
|
||||
<Route exact path="/home" component={Home} />
|
||||
<Route exact path="/stats" component={Stats} />
|
||||
<Route exact path="/habit/:id" component={HabitDetail} />
|
||||
<Route exact path="/account" component={Account} />
|
||||
<Route exact path="/" render={() => <Redirect to="/home" />} />
|
||||
{/* Unprotected Routes */}
|
||||
<Route path="/auth" component={Auth} exact />
|
||||
<Route path="/verify-email" component={VerifyEmail} exact />
|
||||
|
||||
{/* Profile Setup (requires user, but no profile yet) */}
|
||||
<Route path="/setup-profile" component={SetupProfileRoute} exact />
|
||||
|
||||
{/* Non-tab Protected Routes */}
|
||||
<ProtectedRoute
|
||||
path="/event/:id([0-9a-fA-F-]{36})"
|
||||
component={EventDetail}
|
||||
exact
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/admin/events/new"
|
||||
component={AdminEventForm}
|
||||
exact
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/admin/events/:id([0-9a-fA-F-]{36})/edit"
|
||||
component={AdminEventForm}
|
||||
exact
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/news/:id([0-9a-fA-F-]{36})"
|
||||
component={NewsDetail}
|
||||
exact
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/admin/news/new"
|
||||
component={AdminNewsForm}
|
||||
exact
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/admin/news/:id([0-9a-fA-F-]{36})"
|
||||
component={AdminNewsForm}
|
||||
exact
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<ProtectedRoute
|
||||
path={['/home', '/events', '/news', '/profile']}
|
||||
component={() => (
|
||||
<IonTabs>
|
||||
<IonRouterOutlet>
|
||||
<Route path="/home" component={Home} exact />
|
||||
<Route path="/events" component={Events} exact />
|
||||
<Route path="/news" component={News} exact />
|
||||
<Route path="/profile" component={Profile} exact />
|
||||
</IonRouterOutlet>
|
||||
<IonTabBar
|
||||
slot="bottom"
|
||||
style={{
|
||||
'--background': 'var(--color-surface)',
|
||||
'--border': 'none',
|
||||
color: 'var(--color-text-secondary)',
|
||||
boxShadow: '0 -2px 10px rgba(47,34,29,0.05)',
|
||||
}}
|
||||
>
|
||||
<IonTabButton tab="home" href="/home">
|
||||
<IonIcon icon={homeOutline} />
|
||||
<IonLabel>Home</IonLabel>
|
||||
</IonTabButton>
|
||||
<IonTabButton tab="events" href="/events">
|
||||
<IonIcon icon={calendarOutline} />
|
||||
<IonLabel>Events</IonLabel>
|
||||
</IonTabButton>
|
||||
<IonTabButton tab="news" href="/news">
|
||||
<IonIcon icon={newspaperOutline} />
|
||||
<IonLabel>News</IonLabel>
|
||||
</IonTabButton>
|
||||
<IonTabButton tab="profile" href="/profile">
|
||||
<IonIcon icon={personOutline} />
|
||||
<IonLabel>Profile</IonLabel>
|
||||
</IonTabButton>
|
||||
</IonTabBar>
|
||||
</IonTabs>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Root Redirect */}
|
||||
<Route exact path="/">
|
||||
<Redirect to="/auth" />
|
||||
</Route>
|
||||
</IonRouterOutlet>
|
||||
</IonReactRouter>
|
||||
</AuthProvider>
|
||||
</IonApp>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 152 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 771 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 752 KiB |
@@ -1,77 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonProgressBar } from '@ionic/react';
|
||||
|
||||
interface CustomProgressBarProps {
|
||||
value: number; // 0 to 1
|
||||
label?: string;
|
||||
statusText?: string;
|
||||
color?: string; // Hex, rgb, or css variable
|
||||
showPercent?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const CustomProgressBar: React.FC<CustomProgressBarProps> = ({
|
||||
value,
|
||||
label,
|
||||
statusText,
|
||||
color = 'var(--ion-color-primary)',
|
||||
showPercent = true,
|
||||
style,
|
||||
}) => {
|
||||
const percentage = Math.round(Math.min(Math.max(value, 0), 1) * 100);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', ...style }}>
|
||||
{/* Label and Status */}
|
||||
{(label || statusText || showPercent) && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
fontSize: '0.8rem',
|
||||
marginBottom: '6px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--ion-color-medium)' }}>
|
||||
{label}{' '}
|
||||
{statusText && (
|
||||
<span style={{ fontWeight: 'normal', opacity: 0.85 }}>
|
||||
• {statusText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showPercent && (
|
||||
<span
|
||||
style={{
|
||||
color: color,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
fontWeight: '700',
|
||||
}}
|
||||
>
|
||||
{percentage}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Track */}
|
||||
<IonProgressBar
|
||||
value={value}
|
||||
style={
|
||||
{
|
||||
height: '6px',
|
||||
'--background': 'rgba(148, 163, 184, 0.1)',
|
||||
'--progress-background': color,
|
||||
borderRadius: '10px',
|
||||
transition: 'value 0.3s ease',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomProgressBar;
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonButton, IonIcon } from '@ionic/react';
|
||||
import { sparklesOutline } from 'ionicons/icons';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
message: string;
|
||||
actionLabel: string;
|
||||
onAction: () => void;
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
title,
|
||||
message,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}) => {
|
||||
return (
|
||||
<div className="empty-state-card home-empty-state-card">
|
||||
<div className="empty-state-icon">
|
||||
<IonIcon icon={sparklesOutline} />
|
||||
</div>
|
||||
<h2>{title}</h2>
|
||||
<p>{message}</p>
|
||||
<IonButton className="home-empty-state-button" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</IonButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyState;
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { ChurchEvent } from '../types';
|
||||
|
||||
interface EventListCardProps {
|
||||
event: ChurchEvent;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const EventListCard: React.FC<EventListCardProps> = ({ event, onClick }) => {
|
||||
const categoryName = event.event_categories?.name || 'Event';
|
||||
const date = new Date(event.start_at);
|
||||
const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0;
|
||||
|
||||
const startTime = hasTime
|
||||
? date.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: null;
|
||||
|
||||
const dateStr = date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '18px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-brand-soft)',
|
||||
color: 'var(--color-brand)',
|
||||
borderRadius: '999px',
|
||||
padding: '4px 10px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
{dateStr}
|
||||
{startTime ? ` • ${startTime}` : ''}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{categoryName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: '0 0 4px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: '700',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
{event.title}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '13px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-secondary)',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{event.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
{event.venue}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventListCard;
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { ChurchEvent } from '../types';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
import { calendarOutline } from 'ionicons/icons';
|
||||
|
||||
interface EventPreviewCardProps {
|
||||
event: ChurchEvent;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const EventPreviewCard: React.FC<EventPreviewCardProps> = ({
|
||||
event,
|
||||
onClick,
|
||||
}) => {
|
||||
const categoryName = event.event_categories?.name || 'Event';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '8px 0',
|
||||
gap: '10px',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '12px',
|
||||
background: 'var(--color-brand-soft)',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={calendarOutline}
|
||||
style={{ color: 'var(--color-brand)', fontSize: '20px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h4
|
||||
style={{
|
||||
margin: '0 0 2px 0',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-text-primary)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{event.title}
|
||||
</h4>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '12px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-secondary)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const date = new Date(event.start_at);
|
||||
const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0;
|
||||
const timeStr = hasTime
|
||||
? `${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} • `
|
||||
: '';
|
||||
return `${timeStr}${event.day_label}`;
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-brand-soft)',
|
||||
color: 'var(--color-brand)',
|
||||
borderRadius: '999px',
|
||||
padding: '4px 10px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{categoryName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventPreviewCard;
|
||||
@@ -1,108 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonButton, IonIcon, IonSpinner } from '@ionic/react';
|
||||
import {
|
||||
checkmarkOutline,
|
||||
ellipseOutline,
|
||||
flameOutline,
|
||||
trashOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import type { Tables } from '../database.types';
|
||||
|
||||
interface HabitCardProps {
|
||||
habit: Tables<'habits'>;
|
||||
completedToday: boolean;
|
||||
currentStreak: number;
|
||||
completionRate: number;
|
||||
onToggle: () => void;
|
||||
onDelete: () => void;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
const HabitCard: React.FC<HabitCardProps> = ({
|
||||
habit,
|
||||
completedToday,
|
||||
currentStreak,
|
||||
completionRate,
|
||||
onToggle,
|
||||
onDelete,
|
||||
busy,
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`habit-card habit-card--editorial ${completedToday ? 'is-complete' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => history.push(`/habit/${habit.id}`)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
history.push(`/habit/${habit.id}`);
|
||||
}
|
||||
}}
|
||||
aria-label={`View details for ${habit.name}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`habit-check-button ${completedToday ? 'is-complete' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
disabled={busy}
|
||||
aria-label={
|
||||
completedToday
|
||||
? `Mark ${habit.name} as not done`
|
||||
: `Mark ${habit.name} as done`
|
||||
}
|
||||
>
|
||||
{busy ? (
|
||||
<IonSpinner name="crescent" />
|
||||
) : completedToday ? (
|
||||
<IonIcon icon={checkmarkOutline} />
|
||||
) : (
|
||||
<IonIcon icon={ellipseOutline} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="habit-card-main">
|
||||
<div className="habit-copy">
|
||||
<div className="habit-copy-topline">
|
||||
<h3>{habit.name}</h3>
|
||||
<span
|
||||
className="habit-color-pill"
|
||||
style={{ background: habit.color }}
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
{habit.target_description ||
|
||||
'Show up today and keep the chain alive.'}
|
||||
</p>
|
||||
<div className="habit-metrics-row">
|
||||
<span>
|
||||
<IonIcon icon={flameOutline} /> {currentStreak} streak
|
||||
</span>
|
||||
<span>{completionRate}% this week</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IonButton
|
||||
className="habit-delete-button"
|
||||
fill="clear"
|
||||
color="medium"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
<IonIcon icon={trashOutline} />
|
||||
</IonButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HabitCard;
|
||||
@@ -1,153 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonModal,
|
||||
IonTextarea,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from '@ionic/react';
|
||||
|
||||
interface HabitFormModalProps {
|
||||
isOpen: boolean;
|
||||
onDismiss: () => void;
|
||||
onSubmit: (values: {
|
||||
name: string;
|
||||
targetDescription: string;
|
||||
color: string;
|
||||
}) => Promise<void>;
|
||||
initialValues?: {
|
||||
name: string;
|
||||
targetDescription: string;
|
||||
color: string;
|
||||
};
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#7c3aed',
|
||||
'#0f766e',
|
||||
'#ea580c',
|
||||
'#dc2626',
|
||||
'#2563eb',
|
||||
'#059669',
|
||||
];
|
||||
|
||||
const HabitFormModal: React.FC<HabitFormModalProps> = ({
|
||||
isOpen,
|
||||
onDismiss,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
}) => {
|
||||
const isEditing = !!initialValues;
|
||||
const [name, setName] = useState(initialValues?.name ?? '');
|
||||
const [targetDescription, setTargetDescription] = useState(
|
||||
initialValues?.targetDescription ?? ''
|
||||
);
|
||||
const [color, setColor] = useState(initialValues?.color ?? COLORS[0]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() => name.trim().length > 0 && name.trim().length <= 80,
|
||||
[name]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName(initialValues?.name ?? '');
|
||||
setTargetDescription(initialValues?.targetDescription ?? '');
|
||||
setColor(initialValues?.color ?? COLORS[0]);
|
||||
setError('');
|
||||
}
|
||||
}, [isOpen, initialValues]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
name: name.trim(),
|
||||
targetDescription: targetDescription.trim(),
|
||||
color,
|
||||
});
|
||||
setName('');
|
||||
setTargetDescription('');
|
||||
setColor(COLORS[0]);
|
||||
onDismiss();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save habit.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonModal isOpen={isOpen} onDidDismiss={onDismiss}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonTitle>{isEditing ? 'Edit Habit' : 'New Habit'}</IonTitle>
|
||||
<IonButtons slot="end">
|
||||
<IonButton onClick={onDismiss}>Close</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent className="ion-padding">
|
||||
<div className="habit-form-grid">
|
||||
<IonItem lines="none" className="auth-input-item">
|
||||
<IonInput
|
||||
label="Habit name"
|
||||
labelPlacement="stacked"
|
||||
value={name}
|
||||
maxlength={80}
|
||||
placeholder="Drink water"
|
||||
onIonInput={(e) => setName(e.detail.value ?? '')}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<IonItem lines="none" className="auth-input-item">
|
||||
<IonTextarea
|
||||
label="Target or note"
|
||||
labelPlacement="stacked"
|
||||
value={targetDescription}
|
||||
placeholder="8 glasses, 20 minutes, 30 pushups..."
|
||||
autoGrow
|
||||
onIonInput={(e) => setTargetDescription(e.detail.value ?? '')}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<div>
|
||||
<IonLabel className="color-label">Color</IonLabel>
|
||||
<div className="habit-color-row">
|
||||
{COLORS.map((swatch) => (
|
||||
<button
|
||||
key={swatch}
|
||||
type="button"
|
||||
className={`habit-color-dot ${color === swatch ? 'selected' : ''}`}
|
||||
style={{ background: swatch }}
|
||||
onClick={() => setColor(swatch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="auth-feedback error-text">{error}</p> : null}
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || saving}
|
||||
>
|
||||
{saving ? 'Saving…' : isEditing ? 'Save Changes' : 'Create Habit'}
|
||||
</IonButton>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default HabitFormModal;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
|
||||
interface HomeStatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const HomeStatCard: React.FC<HomeStatCardProps> = ({
|
||||
label,
|
||||
value,
|
||||
caption,
|
||||
icon,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '18px',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{icon && (
|
||||
<IonIcon
|
||||
icon={icon}
|
||||
style={{ color: 'var(--color-brand)', fontSize: '16px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-primary)',
|
||||
margin: '4px 0 0 0',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '400',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{caption}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeStatCard;
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { IonInput } from '@ionic/react';
|
||||
|
||||
interface OtpInputRowProps {
|
||||
digits: string[];
|
||||
onChange: (index: number, value: string) => void;
|
||||
onKeyDown: (
|
||||
index: number,
|
||||
e: React.KeyboardEvent<HTMLIonInputElement>
|
||||
) => void;
|
||||
inputRefs: React.RefObject<HTMLIonInputElement>[];
|
||||
}
|
||||
|
||||
const OtpInputRow: React.FC<OtpInputRowProps> = ({
|
||||
digits,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
inputRefs,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
justifyContent: 'center',
|
||||
margin: '0 0 20px 0',
|
||||
}}
|
||||
>
|
||||
{digits.map((digit, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
width: '48px',
|
||||
height: '56px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.05)',
|
||||
}}
|
||||
>
|
||||
<IonInput
|
||||
ref={inputRefs[index]}
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
maxlength={1}
|
||||
value={digit}
|
||||
onIonInput={(e) =>
|
||||
onChange(index, (e.target as HTMLIonInputElement).value as string)
|
||||
}
|
||||
onKeyDown={(e) => onKeyDown(index, e)}
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
textAlign: 'center',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OtpInputRow;
|
||||
@@ -1,69 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonButtons,
|
||||
IonBackButton,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
showBackButton?: boolean;
|
||||
defaultBackHref?: string;
|
||||
endActions?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
toolbarStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
title,
|
||||
showBackButton = false,
|
||||
defaultBackHref = '/home',
|
||||
endActions,
|
||||
style,
|
||||
toolbarStyle,
|
||||
}) => {
|
||||
// Use Ionic lifecycle hook to set status bar style automatically for pages using PageHeader
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark); // Dark text/icons for light-colored headers
|
||||
});
|
||||
|
||||
return (
|
||||
<IonHeader className="ion-no-border" style={style}>
|
||||
<IonToolbar
|
||||
style={
|
||||
{
|
||||
'--background': '#ffffff',
|
||||
'--color': 'var(--ion-color-dark)',
|
||||
'--border-width': '0 0 1px 0',
|
||||
'--border-color': 'rgba(0, 0, 0, 0.05)',
|
||||
...toolbarStyle,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{showBackButton && (
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref={defaultBackHref} />
|
||||
</IonButtons>
|
||||
)}
|
||||
|
||||
<IonTitle
|
||||
style={{
|
||||
fontWeight: '800',
|
||||
fontSize: '1.1rem',
|
||||
letterSpacing: '-0.3px',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</IonTitle>
|
||||
|
||||
{endActions && <IonButtons slot="end">{endActions}</IonButtons>}
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageHeader;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Route, Redirect, RouteProps } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps extends RouteProps {
|
||||
component: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
component: Component,
|
||||
...rest
|
||||
}) => {
|
||||
const { user, profile } = useAuth();
|
||||
|
||||
return (
|
||||
<Route
|
||||
{...rest}
|
||||
render={(props) => {
|
||||
if (!user) {
|
||||
return <Redirect to="/auth" />;
|
||||
}
|
||||
if (profile !== undefined && profile === null) {
|
||||
return <Redirect to="/setup-profile" />;
|
||||
}
|
||||
if (profile !== undefined && profile !== null && !profile.full_name) {
|
||||
return <Redirect to="/setup-profile" />;
|
||||
}
|
||||
return <Component {...props} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
@@ -1,80 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonIcon } from '@ionic/react';
|
||||
|
||||
interface RevisionInstrumentCardProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: string;
|
||||
iconColor: string;
|
||||
iconBgColor: string;
|
||||
onClick: () => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const RevisionInstrumentCard: React.FC<RevisionInstrumentCardProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
iconColor,
|
||||
iconBgColor,
|
||||
onClick,
|
||||
style,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
border: 'none',
|
||||
borderRadius: '14px',
|
||||
padding: '14px 10px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: 'none',
|
||||
transition: 'transform 0.15s ease',
|
||||
...style,
|
||||
}}
|
||||
className="ion-activatable"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '10px',
|
||||
background: iconBgColor,
|
||||
color: iconColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 8px auto',
|
||||
}}
|
||||
>
|
||||
<IonIcon icon={icon} style={{ fontSize: '18px' }} />
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: '700',
|
||||
color: 'var(--ion-color-dark)',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.65rem',
|
||||
color: 'var(--ion-color-medium)',
|
||||
display: 'block',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevisionInstrumentCard;
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IonProgressBar } from '@ionic/react';
|
||||
|
||||
interface StatsHabitCardProps {
|
||||
name: string;
|
||||
color: string;
|
||||
completedCount: number;
|
||||
totalDays: number;
|
||||
streak: number;
|
||||
}
|
||||
|
||||
const StatsHabitCard: React.FC<StatsHabitCardProps> = ({
|
||||
name,
|
||||
color,
|
||||
completedCount,
|
||||
totalDays,
|
||||
streak,
|
||||
}) => {
|
||||
const ratio = totalDays > 0 ? completedCount / totalDays : 0;
|
||||
const percentage = Math.round(ratio * 100);
|
||||
|
||||
return (
|
||||
<div className="stats-card">
|
||||
<div className="stats-card-header">
|
||||
<div>
|
||||
<h3>{name}</h3>
|
||||
<p>
|
||||
{completedCount} of {totalDays} days completed
|
||||
</p>
|
||||
</div>
|
||||
<div className="stats-streak-badge" style={{ color }}>
|
||||
{streak}🔥
|
||||
</div>
|
||||
</div>
|
||||
<IonProgressBar
|
||||
value={ratio}
|
||||
style={{ '--progress-background': color } as React.CSSProperties}
|
||||
/>
|
||||
<div className="stats-card-footer">
|
||||
<span>{percentage}% consistency</span>
|
||||
<span>Current streak {streak}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsHabitCard;
|
||||
@@ -0,0 +1,137 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { supabase } from '../supabase';
|
||||
import { IonSpinner } from '@ionic/react';
|
||||
|
||||
export interface Profile {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
role: string;
|
||||
ministry_group: string | null;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
profile: Profile | null;
|
||||
refreshProfile: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
profile: null,
|
||||
refreshProfile: async () => {},
|
||||
signOut: async () => {},
|
||||
});
|
||||
|
||||
const PROFILE_CACHE_KEY = 'church_app_profile_cache';
|
||||
|
||||
async function getCachedProfile(): Promise<Profile | null> {
|
||||
const cached = localStorage.getItem(PROFILE_CACHE_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function cacheProfile(profile: Profile) {
|
||||
localStorage.setItem(PROFILE_CACHE_KEY, JSON.stringify(profile));
|
||||
}
|
||||
|
||||
async function clearCachedProfile() {
|
||||
localStorage.removeItem(PROFILE_CACHE_KEY);
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
const fetchProfile = async (userId: string) => {
|
||||
const cached = await getCachedProfile();
|
||||
if (cached && cached.id === userId) {
|
||||
setProfile(cached);
|
||||
}
|
||||
const { data } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
if (data) {
|
||||
setProfile(data);
|
||||
await cacheProfile(data);
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
await supabase.auth.signOut();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// One-shot bootstrap
|
||||
supabase.auth.getSession().then(async ({ data }) => {
|
||||
const currentUser = data.session?.user ?? null;
|
||||
setUser(currentUser);
|
||||
if (currentUser) {
|
||||
await fetchProfile(currentUser.id);
|
||||
}
|
||||
setInitializing(false); // only ever called once - IonReactRouter mounts after this
|
||||
});
|
||||
|
||||
// Subsequent events: update state, never touch initializing
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange(async (_event, session) => {
|
||||
const currentUser = session?.user ?? null;
|
||||
setUser(currentUser);
|
||||
if (currentUser) {
|
||||
await fetchProfile(currentUser.id);
|
||||
} else {
|
||||
setProfile(null);
|
||||
await clearCachedProfile();
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
backgroundColor: '#f5f7f4',
|
||||
}}
|
||||
>
|
||||
<IonSpinner name="crescent" color="primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
profile,
|
||||
refreshProfile: () =>
|
||||
user ? fetchProfile(user.id) : Promise.resolve(),
|
||||
signOut,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
+103
-71
@@ -14,122 +14,154 @@ export type Database = {
|
||||
}
|
||||
public: {
|
||||
Tables: {
|
||||
habit_completions: {
|
||||
event_categories: {
|
||||
Row: {
|
||||
completed_on: string
|
||||
accent_color: string | null
|
||||
created_at: string
|
||||
habit_id: string
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
Insert: {
|
||||
completed_on: string
|
||||
accent_color?: string | null
|
||||
created_at?: string
|
||||
habit_id: string
|
||||
id?: string
|
||||
user_id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
Update: {
|
||||
completed_on?: string
|
||||
accent_color?: string | null
|
||||
created_at?: string
|
||||
habit_id?: string
|
||||
id?: string
|
||||
user_id?: string
|
||||
name?: string
|
||||
slug?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
events: {
|
||||
Row: {
|
||||
audience: string
|
||||
category_id: string | null
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
day_label: string
|
||||
description: string
|
||||
end_at: string | null
|
||||
id: string
|
||||
is_featured: boolean
|
||||
start_at: string
|
||||
title: string
|
||||
updated_at: string
|
||||
venue: string
|
||||
}
|
||||
Insert: {
|
||||
audience?: string
|
||||
category_id?: string | null
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
day_label: string
|
||||
description?: string
|
||||
end_at?: string | null
|
||||
id?: string
|
||||
is_featured?: boolean
|
||||
start_at: string
|
||||
title: string
|
||||
updated_at?: string
|
||||
venue: string
|
||||
}
|
||||
Update: {
|
||||
audience?: string
|
||||
category_id?: string | null
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
day_label?: string
|
||||
description?: string
|
||||
end_at?: string | null
|
||||
id?: string
|
||||
is_featured?: boolean
|
||||
start_at?: string
|
||||
title?: string
|
||||
updated_at?: string
|
||||
venue?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "habit_completions_habit_id_fkey"
|
||||
columns: ["habit_id"]
|
||||
foreignKeyName: "events_category_id_fkey"
|
||||
columns: ["category_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "habits"
|
||||
referencedRelation: "event_categories"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
habits: {
|
||||
Row: {
|
||||
color: string
|
||||
created_at: string
|
||||
fcm_token: string | null
|
||||
id: string
|
||||
name: string
|
||||
target_description: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
color?: string
|
||||
created_at?: string
|
||||
fcm_token?: string | null
|
||||
id?: string
|
||||
name: string
|
||||
target_description?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
color?: string
|
||||
created_at?: string
|
||||
fcm_token?: string | null
|
||||
id?: string
|
||||
name?: string
|
||||
target_description?: string | null
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
notes: {
|
||||
news: {
|
||||
Row: {
|
||||
content: string
|
||||
created_at: string
|
||||
created_at: string | null
|
||||
created_by: string
|
||||
id: string
|
||||
is_favorite: boolean
|
||||
image_url: string | null
|
||||
title: string
|
||||
user_id: string
|
||||
updated_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
content: string
|
||||
created_at?: string
|
||||
created_at?: string | null
|
||||
created_by: string
|
||||
id?: string
|
||||
is_favorite?: boolean
|
||||
image_url?: string | null
|
||||
title: string
|
||||
user_id?: string
|
||||
updated_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
content?: string
|
||||
created_at?: string
|
||||
created_at?: string | null
|
||||
created_by?: string
|
||||
id?: string
|
||||
is_favorite?: boolean
|
||||
image_url?: string | null
|
||||
title?: string
|
||||
user_id?: string
|
||||
updated_at?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "news_created_by_fkey"
|
||||
columns: ["created_by"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
todos: {
|
||||
profiles: {
|
||||
Row: {
|
||||
avatar_url: string | null
|
||||
created_at: string
|
||||
description: string | null
|
||||
due_date: string | null
|
||||
email: string
|
||||
full_name: string | null
|
||||
id: string
|
||||
is_completed: boolean
|
||||
title: string
|
||||
user_id: string
|
||||
ministry_group: string | null
|
||||
role: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
avatar_url?: string | null
|
||||
created_at?: string
|
||||
description?: string | null
|
||||
due_date?: string | null
|
||||
id?: string
|
||||
is_completed?: boolean
|
||||
title: string
|
||||
user_id?: string
|
||||
email: string
|
||||
full_name?: string | null
|
||||
id: string
|
||||
ministry_group?: string | null
|
||||
role?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
avatar_url?: string | null
|
||||
created_at?: string
|
||||
description?: string | null
|
||||
due_date?: string | null
|
||||
email?: string
|
||||
full_name?: string | null
|
||||
id?: string
|
||||
is_completed?: boolean
|
||||
title?: string
|
||||
user_id?: string
|
||||
ministry_group?: string | null
|
||||
role?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
|
||||
+10
-5
@@ -5,7 +5,7 @@ import { Capacitor } from '@capacitor/core';
|
||||
import App from './App';
|
||||
|
||||
// ── Ionic initialisation ──────────────────────────────────────────────────────
|
||||
// ?mode=md|ios overrides (used by AppSuite preview); otherwise follow the platform.
|
||||
// ?mode=md|ios overrides (used by Appcakes preview); otherwise follow the platform.
|
||||
const _urlMode = new URLSearchParams(window.location.search).get('mode');
|
||||
const _platform = Capacitor.getPlatform(); // 'ios' | 'android' | 'web'
|
||||
setupIonicReact({
|
||||
@@ -13,7 +13,7 @@ setupIonicReact({
|
||||
});
|
||||
|
||||
// Derive the studio parent origin dynamically so this works in both dev
|
||||
// (parent at localhost:3000) and production (parent at appcakes.qqura.com).
|
||||
// (parent at localhost:3000) and production (parent at studio.appcakes.dev).
|
||||
const studioOrigin = (() => {
|
||||
try {
|
||||
if (document.referrer) return new URL(document.referrer).origin;
|
||||
@@ -49,7 +49,7 @@ if (import.meta.hot) {
|
||||
}
|
||||
|
||||
// ── Session bridge ────────────────────────────────────────────────────────────
|
||||
// Post auth session to the AppSuite parent frame so the AI can test auth-protected
|
||||
// Post auth session to the Appcakes parent frame so the AI can test auth-protected
|
||||
// Edge Functions. Uses import.meta.glob so Vite never errors if supabase.ts is absent.
|
||||
(async () => {
|
||||
const mods = import.meta.glob('./supabase.ts', { eager: false });
|
||||
@@ -73,12 +73,17 @@ if (import.meta.hot) {
|
||||
})();
|
||||
|
||||
// ── Runtime error reporting ───────────────────────────────────────────────────
|
||||
// Forward uncaught errors to the AppSuite dev server so the AI can read them.
|
||||
// Forward uncaught errors to the Appcakes dev server so the AI can read them.
|
||||
// Extract projectId from the preview URL (/preview/{projectId}/) so errors are
|
||||
// scoped per-project on the server rather than mixed into a global queue.
|
||||
const _previewMatch = window.location.pathname.match(/\/preview\/([^/]+)/);
|
||||
const _projectId = _previewMatch?.[1] ?? null;
|
||||
|
||||
function reportError(message: string, stack?: string) {
|
||||
fetch(`${studioOrigin}/api/agent/runtime-error`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, stack }),
|
||||
body: JSON.stringify({ message, stack, projectId: _projectId }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
IonText,
|
||||
} from '@ionic/react';
|
||||
import {
|
||||
waterOutline,
|
||||
fitnessOutline,
|
||||
shieldCheckmarkOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Redirect as RedirectRR, useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import {
|
||||
Style,
|
||||
setStatusBarBackground,
|
||||
setStatusBarStyle,
|
||||
} from '../utils/statusBar';
|
||||
import { useIonViewWillEnter } from '@ionic/react';
|
||||
import runnerDrinkingWaterImage from '../assets/runner-drinking-water-photorealistic.png';
|
||||
|
||||
const RedirectAny = RedirectRR as any;
|
||||
|
||||
const Account: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
const [email, setEmail] = useState<string | null>(null);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [signOutError, setSignOutError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
if (!mounted) return;
|
||||
setEmail(data.session?.user.email ?? null);
|
||||
setSessionChecked(true);
|
||||
});
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
if (!mounted) return;
|
||||
setEmail(session?.user.email ?? null);
|
||||
setSessionChecked(true);
|
||||
|
||||
if (!session) {
|
||||
history.replace('/auth');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [history]);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setSignOutError('');
|
||||
setSigningOut(true);
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
|
||||
if (error) {
|
||||
setSignOutError(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
await FirebaseAuthentication.signOut();
|
||||
} catch {
|
||||
// Ignore if no native Google session exists.
|
||||
}
|
||||
}
|
||||
|
||||
setEmail(null);
|
||||
history.replace('/auth');
|
||||
} catch (error) {
|
||||
setSignOutError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unable to sign out right now.',
|
||||
);
|
||||
} finally {
|
||||
setSigningOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (sessionChecked && !email) {
|
||||
return <RedirectAny to="/auth" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonTitle>Account</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent fullscreen>
|
||||
<div className="dashboard-shell ion-padding account-shell">
|
||||
<section className="account-hero-card">
|
||||
<div className="account-hero-image-wrap">
|
||||
<img
|
||||
src={runnerDrinkingWaterImage}
|
||||
alt="Runner drinking water after a run"
|
||||
className="account-hero-image"
|
||||
/>
|
||||
<div className="account-hero-overlay" />
|
||||
</div>
|
||||
|
||||
<div className="account-hero-content">
|
||||
<span className="eyebrow">Recovery & account</span>
|
||||
<h1>{email ?? 'Loading...'}</h1>
|
||||
<p>
|
||||
Stay hydrated, stay consistent, and keep your habit history
|
||||
safely synced across sessions.
|
||||
</p>
|
||||
|
||||
<div className="account-highlight-row">
|
||||
<div className="account-highlight-chip">
|
||||
<IonIcon icon={waterOutline} />
|
||||
<span>Recovery mindset</span>
|
||||
</div>
|
||||
<div className="account-highlight-chip subtle">
|
||||
<IonIcon icon={fitnessOutline} />
|
||||
<span>Built for routines</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="stats-card account-details-card">
|
||||
<IonItem lines="none" className="account-info-item">
|
||||
<IonLabel>
|
||||
<h2>Email</h2>
|
||||
<p>{email ?? '—'}</p>
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
|
||||
<div className="account-trust-row">
|
||||
<div className="account-trust-icon">
|
||||
<IonIcon icon={shieldCheckmarkOutline} />
|
||||
</div>
|
||||
<div>
|
||||
<h3>Your data is synced</h3>
|
||||
<p>
|
||||
Sign in to keep your streaks and progress available whenever
|
||||
you come back.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
className="account-primary-button"
|
||||
routerLink="/home"
|
||||
>
|
||||
Back to Habits
|
||||
</IonButton>
|
||||
{signOutError ? (
|
||||
<IonText color="danger">
|
||||
<p className="auth-feedback">{signOutError}</p>
|
||||
</IonText>
|
||||
) : null}
|
||||
<div></div>
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill="outline"
|
||||
color="danger"
|
||||
className="account-secondary-button"
|
||||
onClick={handleSignOut}
|
||||
disabled={signingOut}
|
||||
>
|
||||
{signingOut ? 'Signing Out…' : 'Sign Out'}
|
||||
</IonButton>
|
||||
</div>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Account;
|
||||
@@ -0,0 +1,685 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonButtons,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonSpinner,
|
||||
IonSelect,
|
||||
IonSelectOption,
|
||||
IonDatetime,
|
||||
IonModal,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
} from '@ionic/react';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import {
|
||||
chevronBackOutline,
|
||||
calendarOutline,
|
||||
timeOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { EventCategory, ChurchEvent } from '../types';
|
||||
|
||||
const AdminEventForm: React.FC = () => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [categoryId, setCategoryId] = useState('');
|
||||
const [audience, setAudience] = useState<'all' | 'youth' | 'children'>('all');
|
||||
const [venue, setVenue] = useState('');
|
||||
|
||||
// Date states
|
||||
const [startAt, setStartAt] = useState<string>(new Date().toISOString());
|
||||
const [endAt, setEndAt] = useState<string>('');
|
||||
|
||||
const [isFeatured, setIsFeatured] = useState(false);
|
||||
|
||||
const [categories, setCategories] = useState<EventCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEditMode = !!id;
|
||||
|
||||
const history = useHistory();
|
||||
const { user, profile } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (profile && profile.role !== 'admin') {
|
||||
history.replace('/home');
|
||||
return;
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
|
||||
// Load categories
|
||||
const { data: catData, error: catError } = await supabase
|
||||
.from('event_categories')
|
||||
.select('*')
|
||||
.order('name');
|
||||
|
||||
if (catError) {
|
||||
setError(catError.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setCategories(catData as EventCategory[]);
|
||||
|
||||
// If edit mode, load event
|
||||
if (isEditMode) {
|
||||
const { data: eventData, error: eventError } = await supabase
|
||||
.from('events')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (eventError) {
|
||||
setError(eventError.message);
|
||||
} else {
|
||||
const event = eventData as ChurchEvent;
|
||||
setTitle(event.title);
|
||||
setDescription(event.description);
|
||||
setCategoryId(event.category_id);
|
||||
setAudience(event.audience);
|
||||
setVenue(event.venue);
|
||||
setStartAt(event.start_at);
|
||||
setEndAt(event.end_at || '');
|
||||
setIsFeatured(event.is_featured);
|
||||
}
|
||||
} else if (catData && catData.length > 0) {
|
||||
setCategoryId(catData[0].id); // default category
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (profile?.role === 'admin') {
|
||||
loadData();
|
||||
}
|
||||
}, [id, isEditMode, profile, history]);
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setError(msg);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
const getDayLabel = (dateString: string) => {
|
||||
const d = new Date(dateString);
|
||||
return d.toLocaleDateString(undefined, { weekday: 'long' });
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title || !description || !categoryId || !venue || !startAt) {
|
||||
showError('Please fill out all required fields');
|
||||
return;
|
||||
}
|
||||
if (!user || profile?.role !== 'admin') {
|
||||
showError('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const dayLabel = getDayLabel(startAt);
|
||||
const eventPayload = {
|
||||
title,
|
||||
description,
|
||||
category_id: categoryId,
|
||||
audience,
|
||||
venue,
|
||||
start_at: startAt,
|
||||
end_at: endAt || null,
|
||||
day_label: dayLabel,
|
||||
is_featured: isFeatured,
|
||||
};
|
||||
|
||||
if (isEditMode) {
|
||||
const { error: updateError } = await supabase
|
||||
.from('events')
|
||||
.update(eventPayload)
|
||||
.eq('id', id);
|
||||
|
||||
if (updateError) {
|
||||
showError(updateError.message);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const { error: insertError } = await supabase.from('events').insert({
|
||||
...eventPayload,
|
||||
created_by: user.id,
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
showError(insertError.message);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(false);
|
||||
history.goBack();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!isEditMode) return;
|
||||
const confirmDelete = window.confirm(
|
||||
'Are you sure you want to delete this event?'
|
||||
);
|
||||
if (!confirmDelete) return;
|
||||
|
||||
setSaving(true);
|
||||
const { error: deleteError } = await supabase
|
||||
.from('events')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (deleteError) {
|
||||
showError(deleteError.message);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
history.replace('/events');
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage style={{ background: '#f5f7f4' }}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': '#f5f7f4' }}>
|
||||
<IonButtons slot="start">
|
||||
<IonButton onClick={() => history.goBack()} disabled={saving}>
|
||||
<IonIcon
|
||||
icon={chevronBackOutline}
|
||||
style={{ color: '#1f2937' }}
|
||||
slot="icon-only"
|
||||
/>
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle
|
||||
style={{ fontSize: '18px', fontWeight: '700', color: '#1f2937' }}
|
||||
>
|
||||
{isEditMode ? 'Edit Event' : 'New Event'}
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent
|
||||
style={{
|
||||
'--background': '#f5f7f4',
|
||||
'--padding-start': '20px',
|
||||
'--padding-end': '20px',
|
||||
'--padding-top': '16px',
|
||||
'--padding-bottom': '24px',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '40px 0',
|
||||
}}
|
||||
>
|
||||
<IonSpinner name="crescent" color="primary" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '20px',
|
||||
margin: '0 0 16px 0',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: '700',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Event Details
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
Provide the schedule and venue info for members.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(163, 59, 50, 0.10)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 14px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: '#a33b32',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleSave}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '24px',
|
||||
margin: '0 0 16px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Event Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Sunday Service"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
background: '#eef2ec',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Category
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
background: '#eef2ec',
|
||||
borderRadius: '12px',
|
||||
padding: '0 14px',
|
||||
height: '48px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<IonSelect
|
||||
value={categoryId}
|
||||
onIonChange={(e) => setCategoryId(e.detail.value)}
|
||||
interface="popover"
|
||||
style={{
|
||||
width: '100%',
|
||||
color: '#1f2937',
|
||||
fontSize: '15px',
|
||||
}}
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<IonSelectOption key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</IonSelectOption>
|
||||
))}
|
||||
</IonSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audience */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Audience
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAudience('all')}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: audience === 'all' ? '#2f6b4f' : '#eef2ec',
|
||||
color: audience === 'all' ? '#ffffff' : '#4b5563',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
All Members
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAudience('youth')}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: audience === 'youth' ? '#2f6b4f' : '#eef2ec',
|
||||
color: audience === 'youth' ? '#ffffff' : '#4b5563',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Youth Only
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAudience('children')}
|
||||
style={{
|
||||
flex: 1,
|
||||
background:
|
||||
audience === 'children' ? '#2f6b4f' : '#eef2ec',
|
||||
color: audience === 'children' ? '#ffffff' : '#4b5563',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Children
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Venue */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Venue
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Main Auditorium"
|
||||
value={venue}
|
||||
onChange={(e) => setVenue(e.target.value)}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
background: '#eef2ec',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Start Time
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
background: '#eef2ec',
|
||||
borderRadius: '12px',
|
||||
padding: '4px 14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<IonDatetime
|
||||
presentation="date-time"
|
||||
value={startAt}
|
||||
onIonChange={(e) => setStartAt(e.detail.value as string)}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
End Time (Optional)
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
background: '#eef2ec',
|
||||
borderRadius: '12px',
|
||||
padding: '4px 14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<IonDatetime
|
||||
presentation="date-time"
|
||||
value={endAt}
|
||||
onIonChange={(e) =>
|
||||
setEndAt((e.detail.value as string) || '')
|
||||
}
|
||||
showClearButton={true}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
placeholder="Describe the event..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
background: '#eef2ec',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '14px',
|
||||
minHeight: '120px',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
outline: 'none',
|
||||
resize: 'vertical',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Featured Toggle */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 0',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Feature on Home Screen
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFeatured(!isFeatured)}
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '24px',
|
||||
background: isFeatured ? '#2f6b4f' : '#eef2ec',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
transition: '0.2s',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
background: '#ffffff',
|
||||
borderRadius: '50%',
|
||||
position: 'absolute',
|
||||
top: '2px',
|
||||
left: isFeatured ? '22px' : '2px',
|
||||
transition: '0.2s',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{
|
||||
background: '#2f6b4f',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '16px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: '#ffffff',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
{saving ? (
|
||||
<IonSpinner name="dots" color="light" />
|
||||
) : isEditMode ? (
|
||||
'Update Event'
|
||||
) : (
|
||||
'Create Event'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
style={{
|
||||
background: 'rgba(163, 59, 50, 0.10)',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '16px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: '#a33b32',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
Delete Event
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminEventForm;
|
||||
@@ -0,0 +1,330 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonButtons,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonSpinner,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonInput,
|
||||
IonTextarea,
|
||||
} from '@ionic/react';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { chevronBackOutline, saveOutline, trashOutline } from 'ionicons/icons';
|
||||
|
||||
const AdminNewsForm: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = !!id && id !== 'new';
|
||||
const { user, profile } = useAuth();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(isEdit);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setError(msg);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (profile?.role !== 'admin') {
|
||||
history.replace('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
loadNewsItem();
|
||||
}
|
||||
}, [profile, isEdit]);
|
||||
|
||||
const loadNewsItem = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('news')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
if (data) {
|
||||
setTitle(data.title);
|
||||
setContent(data.content);
|
||||
setImageUrl(data.image_url || '');
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title.trim() || !content.trim()) {
|
||||
showError('Title and content are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isEdit) {
|
||||
const { error } = await supabase
|
||||
.from('news')
|
||||
.update({
|
||||
title,
|
||||
content,
|
||||
image_url: imageUrl.trim() || null,
|
||||
})
|
||||
.eq('id', id);
|
||||
if (error) throw error;
|
||||
} else {
|
||||
const { error } = await supabase.from('news').insert({
|
||||
title,
|
||||
content,
|
||||
image_url: imageUrl.trim() || null,
|
||||
created_by: user!.id,
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
history.goBack();
|
||||
} catch (err: any) {
|
||||
showError(err.message);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Delete this news item?')) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { error } = await supabase.from('news').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
history.goBack();
|
||||
} catch (err: any) {
|
||||
showError(err.message);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': '#f5f7f4' }}>
|
||||
<IonButtons slot="start">
|
||||
<IonButton onClick={() => history.goBack()}>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent style={{ '--background': '#f5f7f4' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginTop: '40px',
|
||||
}}
|
||||
>
|
||||
<IonSpinner name="crescent" />
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': '#f5f7f4' }}>
|
||||
<IonButtons slot="start">
|
||||
<IonButton onClick={() => history.goBack()}>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle style={{ fontWeight: '700', fontSize: '18px' }}>
|
||||
{isEdit ? 'Edit News' : 'Create News'}
|
||||
</IonTitle>
|
||||
{isEdit && (
|
||||
<IonButtons slot="end">
|
||||
<IonButton onClick={handleDelete} color="danger">
|
||||
<IonIcon icon={trashOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
)}
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent style={{ '--background': '#f5f7f4' }}>
|
||||
<div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: '#fee2e2',
|
||||
color: '#991b1b',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#4b5563',
|
||||
marginBottom: '8px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<IonItem
|
||||
lines="none"
|
||||
style={{
|
||||
'--background': '#f9faf9',
|
||||
'--border-radius': '12px',
|
||||
'--padding-start': '16px',
|
||||
}}
|
||||
>
|
||||
<IonInput
|
||||
value={title}
|
||||
onIonChange={(e) => setTitle(e.detail.value!)}
|
||||
placeholder="Enter news title"
|
||||
style={{ fontWeight: '500' }}
|
||||
/>
|
||||
</IonItem>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#4b5563',
|
||||
marginBottom: '8px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Image URL (Optional)
|
||||
</label>
|
||||
<IonItem
|
||||
lines="none"
|
||||
style={{
|
||||
'--background': '#f9faf9',
|
||||
'--border-radius': '12px',
|
||||
'--padding-start': '16px',
|
||||
}}
|
||||
>
|
||||
<IonInput
|
||||
value={imageUrl}
|
||||
onIonChange={(e) => setImageUrl(e.detail.value!)}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
</IonItem>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#4b5563',
|
||||
marginBottom: '8px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Content
|
||||
</label>
|
||||
<IonItem
|
||||
lines="none"
|
||||
style={{
|
||||
'--background': '#f9faf9',
|
||||
'--border-radius': '12px',
|
||||
'--padding-start': '16px',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<IonTextarea
|
||||
value={content}
|
||||
onIonChange={(e) => setContent(e.detail.value!)}
|
||||
placeholder="Write your news update here..."
|
||||
autoGrow
|
||||
style={{ minHeight: '150px' }}
|
||||
/>
|
||||
</IonItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: '#2f6b4f',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '16px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
marginTop: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
opacity: loading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<IonSpinner
|
||||
name="crescent"
|
||||
style={{ width: '24px', height: '24px' }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<IonIcon icon={saveOutline} />
|
||||
Save News
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminNewsForm;
|
||||
+263
-338
@@ -1,394 +1,319 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonCard,
|
||||
IonCardContent,
|
||||
IonContent,
|
||||
IonIcon,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonPage,
|
||||
IonSegment,
|
||||
IonSegmentButton,
|
||||
IonText,
|
||||
IonContent,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Redirect, useHistory } from 'react-router-dom';
|
||||
import {
|
||||
Style,
|
||||
setStatusBarBackground,
|
||||
setStatusBarStyle,
|
||||
} from '../utils/statusBar';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const Auth: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const [mode, setMode] = useState<'login' | 'signup'>('login');
|
||||
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [googleLoading, setGoogleLoading] = useState(false);
|
||||
const [appleLoading, setAppleLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [info, setInfo] = useState('');
|
||||
const [verificationMessage, setVerificationMessage] = useState('');
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
const [hasSession, setHasSession] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const history = useHistory();
|
||||
const { user, profile } = useAuth();
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setActiveTab('login');
|
||||
setError(null);
|
||||
});
|
||||
|
||||
const loadProfileAndNavigate = () => {
|
||||
if (!user) return;
|
||||
if (profile !== undefined && profile === null) {
|
||||
history.replace('/setup-profile');
|
||||
} else if (profile !== undefined && profile !== null) {
|
||||
history.replace('/home');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadProfileAndNavigate();
|
||||
}
|
||||
}, [user, profile]);
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setError(msg);
|
||||
setTimeout(() => {
|
||||
setError((current) => (current === msg ? '' : current));
|
||||
}, 4000);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
setStatusBarStyle(Style.Dark);
|
||||
setStatusBarBackground('#f6f7fb');
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
if (!mounted) return;
|
||||
setHasSession(Boolean(data.session));
|
||||
setSessionChecked(true);
|
||||
});
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
if (!mounted) return;
|
||||
setHasSession(Boolean(session));
|
||||
setSessionChecked(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const formValid = useMemo(() => {
|
||||
return email.trim().length > 3 && password.length >= 6;
|
||||
}, [email, password]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
setInfo('');
|
||||
setVerificationMessage('');
|
||||
const handleSignIn = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (mode === 'login') {
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
email: email.trim(),
|
||||
setError(null);
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (signInError) {
|
||||
showError(signInError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
history.replace('/home');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error: signUpError } = await supabase.auth.signUp({
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
|
||||
if (signUpError) {
|
||||
showError(signUpError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.session) {
|
||||
history.replace('/home');
|
||||
return;
|
||||
}
|
||||
|
||||
setVerificationMessage(
|
||||
'Account created! Check your email for a verification link.'
|
||||
);
|
||||
setInfo('Once verified, come back and sign in.');
|
||||
} finally {
|
||||
if (error) {
|
||||
if (error.message.toLowerCase().includes('email not confirmed')) {
|
||||
await supabase.auth.resend({ type: 'signup', email });
|
||||
history.push('/verify-email', { email, resent: true });
|
||||
setLoading(false);
|
||||
return;
|
||||
} else {
|
||||
showError(error.message);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Success: keep loading=true — useEffect above navigates when user arrives in context
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
setError('');
|
||||
setInfo('');
|
||||
setVerificationMessage('');
|
||||
setGoogleLoading(true);
|
||||
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
showError(
|
||||
'Google Sign-In is only available in the native app. Use email to sign in here.'
|
||||
);
|
||||
setGoogleLoading(false);
|
||||
const handleSignUp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
showError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await FirebaseAuthentication.signInWithGoogle();
|
||||
const idToken = result.credential?.idToken;
|
||||
|
||||
if (!idToken) {
|
||||
showError('Google sign-in did not return an ID token.');
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { error } = await supabase.auth.signUp({ email, password });
|
||||
if (error) {
|
||||
showError(error.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: googleAuthError } = await supabase.auth.signInWithIdToken({
|
||||
provider: 'google',
|
||||
token: idToken,
|
||||
});
|
||||
|
||||
if (googleAuthError) {
|
||||
showError(googleAuthError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
history.replace('/home');
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Google sign-in failed.';
|
||||
showError(message);
|
||||
} finally {
|
||||
setGoogleLoading(false);
|
||||
}
|
||||
history.push('/verify-email', { email });
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleAppleLogin = async () => {
|
||||
setError('');
|
||||
setInfo('');
|
||||
setVerificationMessage('');
|
||||
setAppleLoading(true);
|
||||
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
showError(
|
||||
'Apple Sign-In is only available in the native app. Use email to sign in here.'
|
||||
);
|
||||
setAppleLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await FirebaseAuthentication.signInWithApple({
|
||||
skipNativeAuth: true,
|
||||
});
|
||||
const idToken = result.credential?.idToken;
|
||||
const rawNonce = result.credential?.nonce;
|
||||
|
||||
if (!idToken) {
|
||||
showError('Apple sign-in did not return an ID token.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: appleAuthError } = await supabase.auth.signInWithIdToken({
|
||||
provider: 'apple',
|
||||
token: idToken,
|
||||
nonce: rawNonce || undefined,
|
||||
});
|
||||
|
||||
if (appleAuthError) {
|
||||
showError(appleAuthError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
history.replace('/home');
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Apple sign-in failed.';
|
||||
showError(message);
|
||||
} finally {
|
||||
setAppleLoading(false);
|
||||
const handleTabChange = (tab: 'login' | 'register') => {
|
||||
setActiveTab(tab);
|
||||
setError(null);
|
||||
if (tab === 'login') {
|
||||
setConfirmPassword('');
|
||||
}
|
||||
};
|
||||
|
||||
if (sessionChecked && hasSession) {
|
||||
return <Redirect to="/home" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonContent fullscreen style={{ '--background': '#f6f7fb' }}>
|
||||
<div className="auth-shell">
|
||||
<div className="auth-hero">
|
||||
<span className="eyebrow">Protect your streaks</span>
|
||||
<h1>Build habits that actually stick.</h1>
|
||||
<p>
|
||||
Track daily wins, recover faster from misses, and see which habits
|
||||
are getting real momentum.
|
||||
<IonPage style={{ background: '#f6f1e8' }}>
|
||||
<IonContent
|
||||
style={{
|
||||
'--background': 'linear-gradient(180deg, #f9f5ee 0%, #f3ede3 100%)',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': '0px',
|
||||
'--padding-bottom': '0px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding:
|
||||
'calc(var(--ion-safe-area-top, 0px) + 18px) 20px calc(var(--ion-safe-area-bottom, 0px) + 28px)',
|
||||
minHeight: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Welcome header block */}
|
||||
<div
|
||||
style={{
|
||||
background: 'transparent',
|
||||
borderRadius: '0px',
|
||||
padding: '0px',
|
||||
margin: '0 0 24px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#1f2937',
|
||||
lineHeight: '1.2',
|
||||
}}
|
||||
>
|
||||
EAGLES ASSEMBLY
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: '#6b7280',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
Welcome back to our community app
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<IonCard className="auth-card">
|
||||
<IonCardContent>
|
||||
<IonSegment
|
||||
value={mode}
|
||||
onIonChange={(e) =>
|
||||
setMode(e.detail.value as 'login' | 'signup')
|
||||
}
|
||||
{/* Auth mode pill switcher */}
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '999px',
|
||||
padding: '4px',
|
||||
margin: '0 0 20px 0',
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
<IonSegmentButton value="login">
|
||||
<IonLabel>Login</IonLabel>
|
||||
</IonSegmentButton>
|
||||
<IonSegmentButton value="signup">
|
||||
<IonLabel>Sign Up</IonLabel>
|
||||
</IonSegmentButton>
|
||||
</IonSegment>
|
||||
|
||||
{verificationMessage ? (
|
||||
<div className="auth-message-block">
|
||||
<h2>Check your inbox</h2>
|
||||
<p>{verificationMessage}</p>
|
||||
<IonText color="medium">
|
||||
<p>{info}</p>
|
||||
</IonText>
|
||||
<IonButton fill="clear" onClick={() => setMode('login')}>
|
||||
Back to Login
|
||||
</IonButton>
|
||||
<button
|
||||
onClick={() => handleTabChange('login')}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: activeTab === 'login' ? '#2f6b4f' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '12px 18px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: activeTab === 'login' ? '#ffffff' : '#6b7280',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('register')}
|
||||
style={{
|
||||
flex: 1,
|
||||
background:
|
||||
activeTab === 'register' ? '#2f6b4f' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '12px 18px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: activeTab === 'register' ? '#ffffff' : '#6b7280',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="auth-form">
|
||||
<IonItem lines="none" className="auth-input-item">
|
||||
<IonInput
|
||||
|
||||
{/* Form card */}
|
||||
<form
|
||||
onSubmit={activeTab === 'login' ? handleSignIn : handleSignUp}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '24px',
|
||||
margin: '0 0 16px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
label="Email"
|
||||
labelPlacement="stacked"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
placeholder="you@example.com"
|
||||
onIonInput={(e) => setEmail(e.detail.value ?? '')}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
style={{
|
||||
background: '#eef2ec',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
<IonItem lines="none" className="auth-input-item">
|
||||
<IonInput
|
||||
<input
|
||||
type="password"
|
||||
label="Password"
|
||||
labelPlacement="stacked"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
placeholder="At least 6 characters"
|
||||
onIonInput={(e) => setPassword(e.detail.value ?? '')}
|
||||
/>
|
||||
</IonItem>
|
||||
|
||||
{error ? (
|
||||
<IonText color="danger">
|
||||
<p className="auth-feedback">{error}</p>
|
||||
</IonText>
|
||||
) : null}
|
||||
|
||||
{info ? (
|
||||
<IonText color="medium">
|
||||
<p className="auth-feedback">{info}</p>
|
||||
</IonText>
|
||||
) : null}
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
disabled={
|
||||
!formValid || loading || googleLoading || appleLoading
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{loading
|
||||
? 'Please wait…'
|
||||
: mode === 'login'
|
||||
? 'Log In'
|
||||
: 'Create Account'}
|
||||
</IonButton>
|
||||
|
||||
{mode === 'login' ? (
|
||||
<>
|
||||
<div className="auth-divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill="outline"
|
||||
className="google-auth-button"
|
||||
disabled={loading || googleLoading || appleLoading}
|
||||
onClick={handleGoogleLogin}
|
||||
>
|
||||
<span
|
||||
slot="start"
|
||||
className="google-svg-wrap"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
background: '#eef2ec',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
{activeTab === 'register' && (
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
style={{
|
||||
background: '#eef2ec',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: '#2f6b4f',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '16px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: '#ffffff',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginRight: '8px',
|
||||
justifyContent: 'center',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{googleLoading ? 'Connecting…' : 'Continue with Google'}
|
||||
</IonButton>
|
||||
{loading ? (
|
||||
<IonSpinner name="dots" color="light" />
|
||||
) : activeTab === 'login' ? (
|
||||
'Sign In'
|
||||
) : (
|
||||
'Create Account'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill="outline"
|
||||
className="apple-auth-button"
|
||||
disabled={loading || googleLoading || appleLoading}
|
||||
onClick={handleAppleLogin}
|
||||
>
|
||||
<span
|
||||
slot="start"
|
||||
{/* Error area */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
marginRight: '8px',
|
||||
background: 'rgba(190, 60, 60, 0.10)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 14px',
|
||||
margin: '0',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
style={{ display: 'block' }}
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: '#a33b32',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||
</svg>
|
||||
</span>
|
||||
{appleLoading ? 'Connecting…' : 'Continue with Apple'}
|
||||
</IonButton>
|
||||
</>
|
||||
) : null}
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonButtons,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonSpinner,
|
||||
} from '@ionic/react';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { ChurchEvent } from '../types';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import {
|
||||
chevronBackOutline,
|
||||
createOutline,
|
||||
timeOutline,
|
||||
locationOutline,
|
||||
calendarOutline,
|
||||
} from 'ionicons/icons';
|
||||
|
||||
const EventDetail: React.FC = () => {
|
||||
const [event, setEvent] = useState<ChurchEvent | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const history = useHistory();
|
||||
const { profile } = useAuth();
|
||||
|
||||
const loadEvent = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from('events')
|
||||
.select('*, event_categories(*)')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (fetchError) {
|
||||
setError(fetchError.message);
|
||||
setEvent(null);
|
||||
} else {
|
||||
setEvent(data as ChurchEvent);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadEvent();
|
||||
}, [id]);
|
||||
|
||||
const goToEdit = () => {
|
||||
if (!event) return;
|
||||
history.push(`/admin/events/${event.id}/edit`);
|
||||
};
|
||||
|
||||
const isAdmin = profile?.role === 'admin';
|
||||
|
||||
return (
|
||||
<IonPage style={{ background: '#f5f7f4' }}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': '#f5f7f4' }}>
|
||||
<IonButtons slot="start">
|
||||
<IonButton onClick={() => history.goBack()}>
|
||||
<IonIcon
|
||||
icon={chevronBackOutline}
|
||||
style={{ color: '#1f2937' }}
|
||||
slot="icon-only"
|
||||
/>
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle
|
||||
style={{ fontSize: '18px', fontWeight: '700', color: '#1f2937' }}
|
||||
>
|
||||
Event Detail
|
||||
</IonTitle>
|
||||
{isAdmin && (
|
||||
<IonButtons slot="end">
|
||||
<IonButton onClick={goToEdit}>
|
||||
<IonIcon
|
||||
icon={createOutline}
|
||||
style={{ color: '#2f6b4f', fontSize: '20px' }}
|
||||
slot="icon-only"
|
||||
/>
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
)}
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent
|
||||
style={{
|
||||
'--background': '#f5f7f4',
|
||||
'--padding-start': '20px',
|
||||
'--padding-end': '20px',
|
||||
'--padding-top': '16px',
|
||||
'--padding-bottom': '24px',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '40px 0',
|
||||
}}
|
||||
>
|
||||
<IonSpinner name="crescent" color="primary" />
|
||||
</div>
|
||||
) : error || !event ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: 'rgba(190,60,60,0.1)',
|
||||
borderRadius: '12px',
|
||||
color: '#a33b32',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{error || 'Event not found'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header Detail Card */}
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '24px',
|
||||
margin: '0 0 16px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(47, 107, 79, 0.10)',
|
||||
color: '#2f6b4f',
|
||||
borderRadius: '999px',
|
||||
padding: '5px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
{event.event_categories?.name || 'Event'}
|
||||
</div>
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
{event.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Info Rows Card */}
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '20px',
|
||||
margin: '0 0 16px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '14px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
{/* Date Row */}
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '12px' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(47, 107, 79, 0.12)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={calendarOutline}
|
||||
style={{ color: '#2f6b4f', fontSize: '18px' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#7a847f',
|
||||
}}
|
||||
>
|
||||
Date
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{new Date(event.start_at).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Row */}
|
||||
{(() => {
|
||||
const date = new Date(event.start_at);
|
||||
const hasTime =
|
||||
date.getHours() !== 0 || date.getMinutes() !== 0;
|
||||
if (!hasTime) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(47, 107, 79, 0.12)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={timeOutline}
|
||||
style={{ color: '#2f6b4f', fontSize: '18px' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#7a847f',
|
||||
}}
|
||||
>
|
||||
Time
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{date.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
{event.end_at &&
|
||||
` - ${new Date(event.end_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Location Row */}
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '12px' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(47, 107, 79, 0.12)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={locationOutline}
|
||||
style={{ color: '#2f6b4f', fontSize: '18px' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#7a847f',
|
||||
}}
|
||||
>
|
||||
Location
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{event.venue}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* About Card */}
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '20px',
|
||||
margin: '0 0 16px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: '700',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
About
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: '#4b5563',
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{event.description}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetail;
|
||||
@@ -0,0 +1,350 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
IonIcon,
|
||||
IonRefresher,
|
||||
IonRefresherContent,
|
||||
} from '@ionic/react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { ChurchEvent } from '../types';
|
||||
import EventListCard from '../components/EventListCard';
|
||||
import {
|
||||
searchOutline,
|
||||
filterOutline,
|
||||
calendarClearOutline,
|
||||
} from 'ionicons/icons';
|
||||
|
||||
const Events: React.FC = () => {
|
||||
const [allEvents, setAllEvents] = useState<ChurchEvent[]>([]);
|
||||
const [events, setEvents] = useState<ChurchEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState<
|
||||
'all' | 'weekly' | 'youth' | 'sunday-school'
|
||||
>('all');
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
const loadEvents = async (event?: any) => {
|
||||
if (!event) setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const now = new Date();
|
||||
now.setDate(now.getDate() - 1);
|
||||
const dateString = now.toISOString();
|
||||
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from('events')
|
||||
.select('*, event_categories(*)')
|
||||
.gte('start_at', dateString)
|
||||
.order('start_at', { ascending: true });
|
||||
|
||||
if (fetchError) {
|
||||
setError(fetchError.message);
|
||||
setAllEvents([]);
|
||||
setEvents([]);
|
||||
} else {
|
||||
const fetched = data as ChurchEvent[];
|
||||
setAllEvents(fetched);
|
||||
applyFilters(fetched, activeCategory, searchTerm);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
if (event) {
|
||||
event.detail.complete();
|
||||
}
|
||||
};
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
loadEvents();
|
||||
});
|
||||
|
||||
const applyFilters = (
|
||||
source: ChurchEvent[],
|
||||
category: 'all' | 'weekly' | 'youth' | 'sunday-school',
|
||||
search: string
|
||||
) => {
|
||||
let filtered = [...source];
|
||||
|
||||
if (category === 'weekly') {
|
||||
filtered = filtered.filter((e) => e.event_categories?.slug === 'weekly');
|
||||
} else if (category === 'youth') {
|
||||
filtered = filtered.filter(
|
||||
(e) => e.event_categories?.slug === 'youth' || e.audience === 'youth'
|
||||
);
|
||||
} else if (category === 'sunday-school') {
|
||||
filtered = filtered.filter(
|
||||
(e) => e.event_categories?.slug === 'sunday-school'
|
||||
);
|
||||
}
|
||||
|
||||
if (search.trim()) {
|
||||
const term = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(e) =>
|
||||
e.title.toLowerCase().includes(term) ||
|
||||
e.description.toLowerCase().includes(term) ||
|
||||
e.venue.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
setEvents(filtered);
|
||||
};
|
||||
|
||||
const handleCategoryChange = (
|
||||
category: 'all' | 'weekly' | 'youth' | 'sunday-school'
|
||||
) => {
|
||||
setActiveCategory(category);
|
||||
applyFilters(allEvents, category, searchTerm);
|
||||
};
|
||||
|
||||
const handleSearchChange = (val: string) => {
|
||||
setSearchTerm(val);
|
||||
applyFilters(allEvents, activeCategory, val);
|
||||
};
|
||||
|
||||
const openEvent = (id: string) => {
|
||||
history.push(`/event/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage style={{ background: '#f5f7f4' }}>
|
||||
<IonContent
|
||||
style={{
|
||||
'--background': '#f5f7f4',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': '0px',
|
||||
'--padding-bottom': '24px',
|
||||
}}
|
||||
>
|
||||
<IonRefresher slot="fixed" onIonRefresh={loadEvents}>
|
||||
<IonRefresherContent />
|
||||
</IonRefresher>
|
||||
|
||||
{/* Top Title Row */}
|
||||
<div
|
||||
style={{
|
||||
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 8px',
|
||||
margin: '0',
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Events
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
Discover what's happening at church
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Row */}
|
||||
<div style={{ display: 'flex', gap: '8px', margin: '0 20px 12px' }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
height: '44px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 16px',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={searchOutline}
|
||||
style={{ color: '#9aa3a0', fontSize: '18px', flexShrink: 0 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search events..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
outline: 'none',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => handleCategoryChange('all')}
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
background: '#ffffff',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={filterOutline}
|
||||
style={{ color: '#2f6b4f', fontSize: '20px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Chips */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
margin: '0 20px 12px',
|
||||
overflowX: 'auto',
|
||||
paddingBottom: '4px',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
scrollbarWidth: 'none',
|
||||
}}
|
||||
>
|
||||
{['all', 'weekly', 'youth', 'sunday-school'].map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => handleCategoryChange(cat as any)}
|
||||
style={{
|
||||
background: activeCategory === cat ? '#2f6b4f' : '#ffffff',
|
||||
color: activeCategory === cat ? '#ffffff' : '#4b5563',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '8px 14px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow:
|
||||
activeCategory === cat
|
||||
? 'none'
|
||||
: '0 1px 2px rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
{cat === 'all'
|
||||
? 'All Events'
|
||||
: cat === 'sunday-school'
|
||||
? 'Sunday School'
|
||||
: cat.charAt(0).toUpperCase() + cat.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '40px 0',
|
||||
}}
|
||||
>
|
||||
<IonSpinner name="crescent" color="primary" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
style={{
|
||||
margin: '0 20px',
|
||||
padding: '12px',
|
||||
background: 'rgba(190,60,60,0.1)',
|
||||
borderRadius: '12px',
|
||||
color: '#a33b32',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : events.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
margin: '0 20px',
|
||||
}}
|
||||
>
|
||||
{events.map((event) => (
|
||||
<EventListCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
onClick={() => openEvent(event.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '60px 20px',
|
||||
textAlign: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={calendarClearOutline}
|
||||
style={{ fontSize: '44px', color: '#7a847f' }}
|
||||
/>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
No events match this filter.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
handleCategoryChange('all');
|
||||
}}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#2f6b4f',
|
||||
cursor: 'pointer',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
Show all events
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Events;
|
||||
@@ -1,623 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
IonAlert,
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonPage,
|
||||
IonSkeletonText,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { IonBackButton } from '@ionic/react';
|
||||
import {
|
||||
checkmarkOutline,
|
||||
createOutline,
|
||||
ellipseOutline,
|
||||
flameOutline,
|
||||
sparklesOutline,
|
||||
trashOutline,
|
||||
trendingUpOutline,
|
||||
} from 'ionicons/icons';
|
||||
import { Redirect, useHistory, useParams } from 'react-router-dom';
|
||||
import type { Tables } from '../database.types';
|
||||
import { supabase } from '../supabase';
|
||||
import HabitFormModal from '../components/HabitFormModal';
|
||||
import {
|
||||
Style,
|
||||
setStatusBarBackground,
|
||||
setStatusBarStyle,
|
||||
} from '../utils/statusBar';
|
||||
|
||||
interface HabitWithCompletions extends Tables<'habits'> {
|
||||
completions: Tables<'habit_completions'>[];
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||
|
||||
const calculateStreak = (dates: string[]) => {
|
||||
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
|
||||
let streak = 0;
|
||||
let cursor = new Date();
|
||||
|
||||
if (!unique.includes(formatDate(cursor))) {
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
|
||||
while (unique.includes(formatDate(cursor))) {
|
||||
streak += 1;
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
|
||||
return streak;
|
||||
};
|
||||
|
||||
const HabitDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const history = useHistory();
|
||||
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [habit, setHabit] = useState<HabitWithCompletions | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
|
||||
const loadHabit = useCallback(async () => {
|
||||
setError('');
|
||||
|
||||
const {
|
||||
data: { session },
|
||||
error: sessionError,
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (sessionError) {
|
||||
setError(sessionError.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
setUserId(null);
|
||||
setSessionChecked(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setUserId(session.user.id);
|
||||
setSessionChecked(true);
|
||||
|
||||
const { data, error: habitError } = await supabase
|
||||
.from('habits')
|
||||
.select('*, completions:habit_completions(*)')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (habitError) {
|
||||
if (habitError.code === 'PGRST116') {
|
||||
setHabit(null);
|
||||
} else {
|
||||
setError(habitError.message);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setHabit(data as HabitWithCompletions);
|
||||
setLoading(false);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadHabit();
|
||||
}, [loadHabit]);
|
||||
|
||||
const completionDates = useMemo(
|
||||
() => habit?.completions.map((c) => c.completed_on) ?? [],
|
||||
[habit],
|
||||
);
|
||||
|
||||
const today = formatDate(new Date());
|
||||
const doneToday = completionDates.includes(today);
|
||||
|
||||
const currentStreak = useMemo(
|
||||
() => calculateStreak(completionDates),
|
||||
[completionDates],
|
||||
);
|
||||
|
||||
const totalCompletions = completionDates.length;
|
||||
|
||||
// Calculate longest streak
|
||||
const longestStreak = useMemo(() => {
|
||||
const unique = Array.from(new Set(completionDates)).sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
if (unique.length === 0) return 0;
|
||||
let maxStreak = 1;
|
||||
let current = 1;
|
||||
for (let i = 1; i < unique.length; i++) {
|
||||
const prev = new Date(unique[i - 1]);
|
||||
const curr = new Date(unique[i]);
|
||||
const diffDays =
|
||||
(curr.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (diffDays === 1) {
|
||||
current += 1;
|
||||
maxStreak = Math.max(maxStreak, current);
|
||||
} else {
|
||||
current = 1;
|
||||
}
|
||||
}
|
||||
return maxStreak;
|
||||
}, [completionDates]);
|
||||
|
||||
// Days since habit creation (or first completion, whichever is earlier)
|
||||
const daysSinceStart = useMemo(() => {
|
||||
const startDate = habit
|
||||
? new Date(
|
||||
Math.min(
|
||||
new Date(habit.created_at).getTime(),
|
||||
...completionDates.map((d) => new Date(d).getTime()),
|
||||
Date.now(),
|
||||
),
|
||||
)
|
||||
: new Date();
|
||||
const diff = Math.ceil(
|
||||
(Date.now() - startDate.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
return Math.max(diff, 1);
|
||||
}, [habit, completionDates]);
|
||||
|
||||
const consistencyRate = Math.round((totalCompletions / daysSinceStart) * 100);
|
||||
|
||||
// 30-day heatmap data
|
||||
const heatmapDays = useMemo(() => {
|
||||
return Array.from({ length: 30 }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (29 - i));
|
||||
const key = formatDate(date);
|
||||
return {
|
||||
key,
|
||||
completed: completionDates.includes(key),
|
||||
isToday: key === today,
|
||||
};
|
||||
});
|
||||
}, [completionDates, today]);
|
||||
|
||||
const toggleToday = async () => {
|
||||
if (!userId || !habit) return;
|
||||
setBusy(true);
|
||||
|
||||
if (doneToday) {
|
||||
const existing = habit.completions.find((c) => c.completed_on === today);
|
||||
if (existing) {
|
||||
const { error: deleteError } = await supabase
|
||||
.from('habit_completions')
|
||||
.delete()
|
||||
.eq('id', existing.id);
|
||||
if (deleteError) setError(deleteError.message);
|
||||
}
|
||||
} else {
|
||||
const { error: insertError } = await supabase
|
||||
.from('habit_completions')
|
||||
.insert({
|
||||
habit_id: habit.id,
|
||||
user_id: userId,
|
||||
completed_on: today,
|
||||
});
|
||||
if (insertError) setError(insertError.message);
|
||||
}
|
||||
|
||||
await loadHabit();
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const updateHabit = async (values: {
|
||||
name: string;
|
||||
targetDescription: string;
|
||||
color: string;
|
||||
}) => {
|
||||
if (!userId || !habit) throw new Error('Habit not found.');
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('habits')
|
||||
.update({
|
||||
name: values.name,
|
||||
target_description: values.targetDescription || null,
|
||||
color: values.color,
|
||||
})
|
||||
.eq('id', habit.id);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
await loadHabit();
|
||||
};
|
||||
|
||||
const deleteHabit = async () => {
|
||||
if (!habit) return;
|
||||
setBusy(true);
|
||||
const { error: deleteError } = await supabase
|
||||
.from('habits')
|
||||
.delete()
|
||||
.eq('id', habit.id);
|
||||
if (deleteError) {
|
||||
setError(deleteError.message);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
history.push('/home');
|
||||
};
|
||||
|
||||
if (sessionChecked && !userId) {
|
||||
return <Redirect to="/auth" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref="/home" />
|
||||
</IonButtons>
|
||||
<IonTitle>{habit?.name ?? 'Habit'}</IonTitle>
|
||||
<IonButtons slot="end">
|
||||
{habit && (
|
||||
<>
|
||||
<IonButton fill="clear" onClick={() => setShowEditModal(true)}>
|
||||
<IonIcon icon={createOutline} />
|
||||
</IonButton>
|
||||
<IonButton
|
||||
fill="clear"
|
||||
color="danger"
|
||||
onClick={() => setShowDeleteAlert(true)}
|
||||
>
|
||||
<IonIcon icon={trashOutline} />
|
||||
</IonButton>
|
||||
</>
|
||||
)}
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent fullscreen>
|
||||
<div className="dashboard-shell ion-padding">
|
||||
{/* Loading state */}
|
||||
{loading ? (
|
||||
<>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{ width: '60%', height: '24px', borderRadius: '8px' }}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '120px',
|
||||
borderRadius: '24px',
|
||||
}}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '160px',
|
||||
borderRadius: '24px',
|
||||
}}
|
||||
/>
|
||||
<IonSkeletonText
|
||||
animated
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100px',
|
||||
borderRadius: '24px',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : error ? (
|
||||
/* Error state */
|
||||
<div className="inline-message error-card">
|
||||
<h3>Something went wrong</h3>
|
||||
<p>{error}</p>
|
||||
<IonButton onClick={loadHabit}>Try Again</IonButton>
|
||||
</div>
|
||||
) : !habit ? (
|
||||
/* Not found state */
|
||||
<div className="inline-message">
|
||||
<h3>Habit not found</h3>
|
||||
<p>
|
||||
This habit may have been deleted or you don't have access
|
||||
to it.
|
||||
</p>
|
||||
<IonButton routerLink="/home">Back to habits</IonButton>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Habit info card */}
|
||||
<section className="hero-card compact">
|
||||
<div className="habit-copy-topline">
|
||||
<span
|
||||
className="habit-color-pill"
|
||||
style={{ background: habit.color }}
|
||||
/>
|
||||
<span className="eyebrow">Your routine</span>
|
||||
</div>
|
||||
<h1>{habit.name}</h1>
|
||||
<p>
|
||||
{habit.target_description ||
|
||||
'Show up today and keep the chain alive.'}
|
||||
</p>
|
||||
|
||||
{/* Today toggle */}
|
||||
<div style={{ marginTop: '18px' }}>
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill={doneToday ? 'solid' : 'outline'}
|
||||
onClick={toggleToday}
|
||||
disabled={busy}
|
||||
style={
|
||||
doneToday
|
||||
? ({
|
||||
'--background': habit.color,
|
||||
'--background-hover': habit.color,
|
||||
'--background-activated': habit.color,
|
||||
'--border-radius': '18px',
|
||||
minHeight: '50px',
|
||||
} as any)
|
||||
: ({
|
||||
'--border-color': habit.color,
|
||||
'--color': habit.color,
|
||||
'--border-radius': '18px',
|
||||
minHeight: '50px',
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<IonIcon
|
||||
icon={doneToday ? checkmarkOutline : ellipseOutline}
|
||||
slot="start"
|
||||
/>
|
||||
{doneToday ? 'Completed today' : "Mark today's habit done"}
|
||||
</IonButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* All-time stats */}
|
||||
<section>
|
||||
<p className="home-section-label">All-time stats</p>
|
||||
<div className="stack-list">
|
||||
{/* Stats row */}
|
||||
<div
|
||||
className="hero-card compact"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: '12px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '1.8rem',
|
||||
fontWeight: 800,
|
||||
color: habit.color,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{totalCompletions}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--ion-color-medium)',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
total done
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '1.8rem',
|
||||
fontWeight: 800,
|
||||
color: habit.color,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{longestStreak}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--ion-color-medium)',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
best streak
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '1.8rem',
|
||||
fontWeight: 800,
|
||||
color: habit.color,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{consistencyRate}%
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--ion-color-medium)',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
consistency
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current streak highlight */}
|
||||
<div
|
||||
className="hero-card compact"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '14px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '16px',
|
||||
background: `${habit.color}18`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={flameOutline}
|
||||
style={{ color: habit.color, fontSize: '1.3rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 700,
|
||||
color: 'var(--ion-color-dark)',
|
||||
}}
|
||||
>
|
||||
{currentStreak} day{currentStreak === 1 ? '' : 's'} and
|
||||
counting
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: '4px 0 0',
|
||||
color: 'var(--ion-color-medium)',
|
||||
fontSize: '0.84rem',
|
||||
}}
|
||||
>
|
||||
{currentStreak > 0
|
||||
? `You've shown up every day since ${(() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - currentStreak + 1);
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
})()}.`
|
||||
: "Start today and build a streak you're proud of."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 30-day heatmap */}
|
||||
<section>
|
||||
<p className="home-section-label">Last 30 days</p>
|
||||
<div className="hero-card compact">
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{heatmapDays.map((day) => (
|
||||
<div
|
||||
key={day.key}
|
||||
title={
|
||||
day.isToday
|
||||
? 'Today'
|
||||
: new Date(day.key).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
style={{
|
||||
aspectRatio: '1',
|
||||
borderRadius: '6px',
|
||||
background: day.completed
|
||||
? habit.color
|
||||
: `${habit.color}14`,
|
||||
border: day.isToday
|
||||
? `2px solid ${habit.color}`
|
||||
: '2px solid transparent',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: '12px',
|
||||
fontSize: '0.74rem',
|
||||
color: 'var(--ion-color-medium)',
|
||||
}}
|
||||
>
|
||||
<span>30 days ago</span>
|
||||
<span>
|
||||
<IonIcon
|
||||
icon={trendingUpOutline}
|
||||
style={{ verticalAlign: 'middle', marginRight: '4px' }}
|
||||
/>
|
||||
{heatmapDays.filter((d) => d.completed).length} of 30 days
|
||||
</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Edit modal */}
|
||||
{showEditModal && (
|
||||
<HabitFormModal
|
||||
isOpen={showEditModal}
|
||||
onDismiss={() => setShowEditModal(false)}
|
||||
onSubmit={updateHabit}
|
||||
initialValues={{
|
||||
name: habit.name,
|
||||
targetDescription: habit.target_description ?? '',
|
||||
color: habit.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<IonAlert
|
||||
isOpen={showDeleteAlert}
|
||||
onDidDismiss={() => setShowDeleteAlert(false)}
|
||||
header="Delete habit?"
|
||||
message={`Are you sure you want to delete "${habit?.name ?? ''}"? This will also remove all its completion history. This action cannot be undone.`}
|
||||
buttons={[
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
role: 'destructive',
|
||||
handler: deleteHabit,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default HabitDetail;
|
||||
+529
-431
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,229 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
IonRefresher,
|
||||
IonRefresherContent,
|
||||
IonButtons,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
} from '@ionic/react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { NewsItem } from '../types';
|
||||
import { addOutline, newspaperOutline } from 'ionicons/icons';
|
||||
|
||||
const News: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { profile } = useAuth();
|
||||
const [news, setNews] = useState<NewsItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchNews = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('news')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setNews(data || []);
|
||||
} catch (err) {
|
||||
console.error('Error fetching news:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
fetchNews();
|
||||
});
|
||||
|
||||
const doRefresh = async (event: any) => {
|
||||
await fetchNews();
|
||||
event.detail.complete();
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': '#f5f7f4' }}>
|
||||
<IonTitle
|
||||
style={{ fontWeight: '700', fontSize: '24px', color: '#1f2937' }}
|
||||
>
|
||||
News
|
||||
</IonTitle>
|
||||
{profile?.role === 'admin' && (
|
||||
<IonButtons slot="end">
|
||||
<IonButton onClick={() => history.push('/admin/news/new')}>
|
||||
<IonIcon icon={addOutline} style={{ color: '#2f6b4f' }} />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
)}
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent style={{ '--background': '#f5f7f4' }}>
|
||||
<IonRefresher slot="fixed" onIonRefresh={doRefresh}>
|
||||
<IonRefresherContent />
|
||||
</IonRefresher>
|
||||
|
||||
<div style={{ padding: '0 20px 20px' }}>
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginTop: '40px',
|
||||
}}
|
||||
>
|
||||
<IonSpinner name="crescent" />
|
||||
</div>
|
||||
) : news.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={newspaperOutline}
|
||||
style={{
|
||||
fontSize: '48px',
|
||||
color: '#cbd5e1',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
/>
|
||||
<p style={{ margin: 0, fontSize: '16px', fontWeight: '500' }}>
|
||||
No news yet.
|
||||
</p>
|
||||
{profile?.role === 'admin' && (
|
||||
<button
|
||||
onClick={() => history.push('/admin/news/new')}
|
||||
style={{
|
||||
background: '#2f6b4f',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '10px 20px',
|
||||
fontWeight: '600',
|
||||
marginTop: '20px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Create Post
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
paddingTop: '8px',
|
||||
}}
|
||||
>
|
||||
{news.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => history.push(`/news/${item.id}`)}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{item.image_url ? (
|
||||
<img
|
||||
src={item.image_url}
|
||||
alt=""
|
||||
style={{
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
borderRadius: '16px',
|
||||
objectFit: 'cover',
|
||||
flexShrink: 0,
|
||||
background: '#f1f5f9',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
borderRadius: '16px',
|
||||
background: 'rgba(47, 107, 79, 0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={newspaperOutline}
|
||||
style={{ color: '#2f6b4f', fontSize: '28px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#9ca3af',
|
||||
}}
|
||||
>
|
||||
{new Date(item.created_at).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '700',
|
||||
color: '#1f2937',
|
||||
lineHeight: '1.3',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default News;
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonButtons,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonSpinner,
|
||||
useIonViewWillEnter,
|
||||
} from '@ionic/react';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { NewsItem } from '../types';
|
||||
import { chevronBackOutline, createOutline } from 'ionicons/icons';
|
||||
|
||||
const NewsDetail: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { profile } = useAuth();
|
||||
const [news, setNews] = useState<NewsItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useIonViewWillEnter(() => {
|
||||
fetchNewsItem();
|
||||
});
|
||||
|
||||
const fetchNewsItem = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('news')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
setNews(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching news:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': '#f5f7f4' }}>
|
||||
<IonButtons slot="start">
|
||||
<IonButton onClick={() => history.goBack()}>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent style={{ '--background': '#f5f7f4' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginTop: '40px',
|
||||
}}
|
||||
>
|
||||
<IonSpinner name="crescent" />
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
|
||||
if (!news) {
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': '#f5f7f4' }}>
|
||||
<IonButtons slot="start">
|
||||
<IonButton onClick={() => history.goBack()}>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent style={{ '--background': '#f5f7f4' }}>
|
||||
<div
|
||||
style={{ textAlign: 'center', marginTop: '60px', color: '#6b7280' }}
|
||||
>
|
||||
News item not found.
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': '#f5f7f4' }}>
|
||||
<IonButtons slot="start">
|
||||
<IonButton onClick={() => history.goBack()}>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
{profile?.role === 'admin' && (
|
||||
<IonButtons slot="end">
|
||||
<IonButton onClick={() => history.push(`/admin/news/${news.id}`)}>
|
||||
<IonIcon
|
||||
icon={createOutline}
|
||||
slot="icon-only"
|
||||
style={{ color: '#2f6b4f' }}
|
||||
/>
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
)}
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent style={{ '--background': '#f5f7f4' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '0 20px 40px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
{news.image_url && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<img
|
||||
src={news.image_url}
|
||||
alt=""
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '240px',
|
||||
objectFit: 'cover',
|
||||
borderRadius: '24px',
|
||||
background: '#eef2ec',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#2f6b4f',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
{new Date(news.created_at).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h1
|
||||
style={{
|
||||
margin: '0 0 24px',
|
||||
fontSize: '28px',
|
||||
fontWeight: '800',
|
||||
color: '#1f2937',
|
||||
lineHeight: '1.2',
|
||||
}}
|
||||
>
|
||||
{news.title}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
color: '#4b5563',
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{news.content}
|
||||
</div>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsDetail;
|
||||
@@ -0,0 +1,521 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { IonPage, IonContent, IonIcon, IonSpinner } from '@ionic/react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import {
|
||||
cameraOutline,
|
||||
personOutline,
|
||||
chevronForwardOutline,
|
||||
settingsOutline,
|
||||
helpCircleOutline,
|
||||
} from 'ionicons/icons';
|
||||
|
||||
const Profile: React.FC = () => {
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { user, profile, refreshProfile } = useAuth();
|
||||
const history = useHistory();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setSigningOut(true);
|
||||
setError(null);
|
||||
const { error: signOutError } = await supabase.auth.signOut();
|
||||
if (signOutError) {
|
||||
setError(signOutError.message);
|
||||
setSigningOut(false);
|
||||
return;
|
||||
}
|
||||
history.replace('/auth');
|
||||
};
|
||||
|
||||
const goToCreateEvent = () => {
|
||||
history.push('/admin/events/new');
|
||||
};
|
||||
|
||||
const initials = profile?.full_name
|
||||
? profile.full_name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.substring(0, 2)
|
||||
.toUpperCase()
|
||||
: 'U';
|
||||
|
||||
const isAdmin = profile?.role === 'admin';
|
||||
const avatarUrl = profile?.avatar_url
|
||||
? `${supabase.storage.from('avatars').getPublicUrl(profile.avatar_url).data.publicUrl}?t=${Date.now()}`
|
||||
: null;
|
||||
|
||||
const showError = (message: string) => {
|
||||
setError(message);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
const handleAvatarSelect = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || !user) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadingAvatar(true);
|
||||
setError(null);
|
||||
|
||||
const extension = file.name.split('.').pop()?.toLowerCase() || 'jpg';
|
||||
const filePath = `${user.id}/avatar-${Date.now()}.${extension}`;
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('avatars')
|
||||
.upload(filePath, file);
|
||||
|
||||
if (uploadError) {
|
||||
showError(uploadError.message);
|
||||
setUploadingAvatar(false);
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.update({ avatar_url: filePath, updated_at: new Date().toISOString() })
|
||||
.eq('id', user.id);
|
||||
|
||||
if (profileError) {
|
||||
showError(profileError.message);
|
||||
setUploadingAvatar(false);
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshProfile();
|
||||
setUploadingAvatar(false);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage style={{ background: '#f5f7f4' }}>
|
||||
<IonContent
|
||||
style={{
|
||||
'--background': '#f5f7f4',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': '0px',
|
||||
'--padding-bottom': '24px',
|
||||
}}
|
||||
>
|
||||
{/* Top Title Row */}
|
||||
<div
|
||||
style={{
|
||||
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 8px',
|
||||
margin: '0',
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Profile
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
Manage your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Summary Card */}
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '20px',
|
||||
margin: '0 20px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '52px',
|
||||
height: '52px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingAvatar}
|
||||
style={{
|
||||
width: '52px',
|
||||
height: '52px',
|
||||
borderRadius: '16px',
|
||||
background: 'rgba(47, 107, 79, 0.12)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px',
|
||||
fontWeight: '700',
|
||||
color: '#2f6b4f',
|
||||
flexShrink: 0,
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
cursor: uploadingAvatar ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={profile?.full_name || 'Profile'}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingAvatar}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '-4px',
|
||||
bottom: '-4px',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
background: '#2f6b4f',
|
||||
color: '#ffffff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: uploadingAvatar ? 'default' : 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{uploadingAvatar ? (
|
||||
<IonSpinner
|
||||
name="crescent"
|
||||
color="light"
|
||||
style={{ width: '14px', height: '14px' }}
|
||||
/>
|
||||
) : (
|
||||
<IonIcon icon={cameraOutline} style={{ fontSize: '13px' }} />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleAvatarSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: '700',
|
||||
color: '#1f2937',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{profile?.full_name || 'Member'}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '400',
|
||||
color: '#6b7280',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{profile?.email || ''}
|
||||
</div>
|
||||
</div>
|
||||
{profile?.ministry_group && (
|
||||
<div
|
||||
style={{
|
||||
background: '#eef2ec',
|
||||
color: '#4b5563',
|
||||
borderRadius: '999px',
|
||||
padding: '4px 10px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
{profile.ministry_group}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings / Action List Card */}
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '8px 18px',
|
||||
margin: '0 20px 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
minHeight: '56px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '12px',
|
||||
background: '#eef2ec',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={personOutline}
|
||||
style={{ color: '#2f6b4f', fontSize: '18px' }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Personal Info
|
||||
</div>
|
||||
<IonIcon
|
||||
icon={chevronForwardOutline}
|
||||
style={{ color: '#9aa3a0', fontSize: '18px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
minHeight: '56px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '12px',
|
||||
background: '#eef2ec',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={settingsOutline}
|
||||
style={{ color: '#2f6b4f', fontSize: '18px' }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Account Settings
|
||||
</div>
|
||||
<IonIcon
|
||||
icon={chevronForwardOutline}
|
||||
style={{ color: '#9aa3a0', fontSize: '18px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
minHeight: '56px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '12px',
|
||||
background: '#eef2ec',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={helpCircleOutline}
|
||||
style={{ color: '#2f6b4f', fontSize: '18px' }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Help & Support
|
||||
</div>
|
||||
<IonIcon
|
||||
icon={chevronForwardOutline}
|
||||
style={{ color: '#9aa3a0', fontSize: '18px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Shortcut Card */}
|
||||
{isAdmin && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(47, 107, 79, 0.08)',
|
||||
borderRadius: '24px',
|
||||
padding: '18px',
|
||||
margin: '0 20px 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ fontSize: '14px', fontWeight: '600', color: '#2f6b4f' }}
|
||||
>
|
||||
Admin Tools
|
||||
</div>
|
||||
<button
|
||||
onClick={goToCreateEvent}
|
||||
style={{
|
||||
background: '#2f6b4f',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '14px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#ffffff',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
Create New Event
|
||||
</button>
|
||||
<button
|
||||
onClick={() => history.push('/admin/news/new')}
|
||||
style={{
|
||||
background: 'rgba(47, 107, 79, 0.1)',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '14px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#2f6b4f',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
Manage News
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sign Out Button */}
|
||||
<div style={{ margin: '0 20px 24px 20px' }}>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
disabled={signingOut}
|
||||
style={{
|
||||
background: '#a33b32',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '16px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: '#ffffff',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{signingOut ? <IonSpinner name="dots" color="light" /> : 'Sign Out'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
margin: '0 20px',
|
||||
padding: '12px',
|
||||
background: 'rgba(190,60,60,0.1)',
|
||||
borderRadius: '12px',
|
||||
color: '#a33b32',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IonPage,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonSpinner,
|
||||
IonSelect,
|
||||
IonSelectOption,
|
||||
IonIcon,
|
||||
} from '@ionic/react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { personAddOutline } from 'ionicons/icons';
|
||||
|
||||
const SetupProfile: React.FC = () => {
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [ministryGroup, setMinistryGroup] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const history = useHistory();
|
||||
const { user, refreshProfile } = useAuth();
|
||||
|
||||
const showError = (msg: string) => {
|
||||
setError(msg);
|
||||
setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
const handleProfileSetup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!fullName.trim()) {
|
||||
showError('Please enter your name');
|
||||
return;
|
||||
}
|
||||
if (!user) {
|
||||
showError('No user session found. Please log in again.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { error: insertError } = await supabase.from('profiles').insert({
|
||||
id: user.id,
|
||||
email: user.email ?? '',
|
||||
full_name: fullName.trim(),
|
||||
role: 'member',
|
||||
ministry_group: ministryGroup || null,
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
showError(insertError.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshProfile();
|
||||
history.replace('/home');
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage style={{ background: '#f5f7f4' }}>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': '#f5f7f4' }}>
|
||||
<IonTitle
|
||||
style={{ fontSize: '18px', fontWeight: '700', color: '#1f2937' }}
|
||||
>
|
||||
Setup Profile
|
||||
</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent
|
||||
style={{
|
||||
'--background': '#f5f7f4',
|
||||
'--padding-start': '20px',
|
||||
'--padding-end': '20px',
|
||||
'--padding-top': '16px',
|
||||
'--padding-bottom': '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '24px',
|
||||
margin: '0 0 16px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(47, 107, 79, 0.12)',
|
||||
borderRadius: '12px',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={personAddOutline}
|
||||
style={{ fontSize: '20px', color: '#2f6b4f' }}
|
||||
/>
|
||||
</div>
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '20px',
|
||||
fontWeight: '700',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Welcome to EAGLES ASSEMBLY
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
Please let us know who you are so we can personalize your
|
||||
experience.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleProfileSetup}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '24px',
|
||||
margin: '0 0 16px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Full Name (e.g. John Doe)"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
required
|
||||
style={{
|
||||
background: '#eef2ec',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '0 14px',
|
||||
height: '48px',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: '#eef2ec',
|
||||
borderRadius: '12px',
|
||||
padding: '0 14px',
|
||||
height: '48px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<IonSelect
|
||||
value={ministryGroup}
|
||||
placeholder="Select Ministry Group (Optional)"
|
||||
onIonChange={(e) => setMinistryGroup(e.detail.value)}
|
||||
interface="popover"
|
||||
style={{
|
||||
width: '100%',
|
||||
color: '#1f2937',
|
||||
fontSize: '15px',
|
||||
'--placeholder-color': '#9aa3a0',
|
||||
'--placeholder-opacity': '1',
|
||||
}}
|
||||
>
|
||||
<IonSelectOption value="Youth">Youth</IonSelectOption>
|
||||
<IonSelectOption value="Choir">Choir</IonSelectOption>
|
||||
<IonSelectOption value="Ushering">Ushering</IonSelectOption>
|
||||
<IonSelectOption value="Media">Media</IonSelectOption>
|
||||
</IonSelect>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: '#2f6b4f',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '16px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: '#ffffff',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<IonSpinner name="dots" color="light" />
|
||||
) : (
|
||||
'Complete Setup'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(190, 60, 60, 0.10)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 14px',
|
||||
margin: '0',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: '#a33b32',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupProfile;
|
||||
@@ -1,174 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonPage,
|
||||
IonSegment,
|
||||
IonSegmentButton,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from '@ionic/react';
|
||||
import { IonBackButton } from '@ionic/react';
|
||||
import { calendarOutline } from 'ionicons/icons';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import type { Tables } from '../database.types';
|
||||
import StatsHabitCard from '../components/StatsHabitCard';
|
||||
import { supabase } from '../supabase';
|
||||
import {
|
||||
Style,
|
||||
setStatusBarBackground,
|
||||
setStatusBarStyle,
|
||||
} from '../utils/statusBar';
|
||||
import { useIonViewWillEnter } from '@ionic/react';
|
||||
|
||||
interface HabitWithMeta extends Tables<'habits'> {
|
||||
completions: Tables<'habit_completions'>[];
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||
|
||||
const calculateCurrentStreak = (dates: string[]) => {
|
||||
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
|
||||
let streak = 0;
|
||||
let cursor = new Date();
|
||||
|
||||
if (!unique.includes(formatDate(cursor))) {
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
|
||||
while (unique.includes(formatDate(cursor))) {
|
||||
streak += 1;
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
|
||||
return streak;
|
||||
};
|
||||
|
||||
const Stats: React.FC = () => {
|
||||
const [period, setPeriod] = useState<'7' | '30'>('7');
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session?.user) {
|
||||
setSessionChecked(true);
|
||||
setUserId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setUserId(session.user.id);
|
||||
setSessionChecked(true);
|
||||
|
||||
const { data } = await supabase
|
||||
.from('habits')
|
||||
.select('*, completions:habit_completions(*)')
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
setHabits((data ?? []) as HabitWithMeta[]);
|
||||
};
|
||||
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const days = Number(period);
|
||||
const visibleDates = useMemo(() => {
|
||||
return Array.from({ length: days }, (_, index) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - index);
|
||||
return formatDate(date);
|
||||
});
|
||||
}, [days]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
return habits.map((habit) => {
|
||||
const completionDates = habit.completions.map(
|
||||
(entry) => entry.completed_on,
|
||||
);
|
||||
const completedCount = completionDates.filter((date) =>
|
||||
visibleDates.includes(date),
|
||||
).length;
|
||||
return {
|
||||
id: habit.id,
|
||||
name: habit.name,
|
||||
color: habit.color,
|
||||
completedCount,
|
||||
streak: calculateCurrentStreak(completionDates),
|
||||
};
|
||||
});
|
||||
}, [habits, visibleDates]);
|
||||
|
||||
if (sessionChecked && !userId) {
|
||||
return <Redirect to="/auth" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref="/home" />
|
||||
</IonButtons>
|
||||
<IonTitle>Stats</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent fullscreen>
|
||||
<div className="dashboard-shell ion-padding">
|
||||
<section className="hero-card compact">
|
||||
<span className="eyebrow">Consistency view</span>
|
||||
<h1>See which habits you're actually keeping.</h1>
|
||||
<p>
|
||||
Flip between the last week and month to spot patterns before a
|
||||
streak disappears.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="stats-filter-row">
|
||||
<IonIcon icon={calendarOutline} />
|
||||
<IonSegment
|
||||
value={period}
|
||||
onIonChange={(e) => setPeriod(e.detail.value as '7' | '30')}
|
||||
>
|
||||
<IonSegmentButton value="7">Last 7 days</IonSegmentButton>
|
||||
<IonSegmentButton value="30">Last 30 days</IonSegmentButton>
|
||||
</IonSegment>
|
||||
</div>
|
||||
|
||||
{summary.length === 0 ? (
|
||||
<div className="inline-message">
|
||||
<h3>No stats yet</h3>
|
||||
<p>
|
||||
Create a habit and start checking it off daily to unlock
|
||||
consistency insights.
|
||||
</p>
|
||||
<IonButton routerLink="/home">Go to dashboard</IonButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="stack-list">
|
||||
{summary.map((habit) => (
|
||||
<StatsHabitCard
|
||||
key={habit.id}
|
||||
name={habit.name}
|
||||
color={habit.color}
|
||||
completedCount={habit.completedCount}
|
||||
totalDays={days}
|
||||
streak={habit.streak}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Stats;
|
||||
@@ -0,0 +1,296 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { IonPage, IonContent, IonSpinner, IonIcon } from '@ionic/react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import OtpInputRow from '../components/OtpInputRow';
|
||||
import { checkmarkCircleOutline } from 'ionicons/icons';
|
||||
|
||||
const VerifyEmail: React.FC = () => {
|
||||
const [digits, setDigits] = useState<string[]>(Array(6).fill(''));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const [verified, setVerified] = useState(false);
|
||||
|
||||
const history = useHistory();
|
||||
const location = useLocation<{ email?: string; resent?: boolean }>();
|
||||
const email = location.state?.email || '';
|
||||
const resent = location.state?.resent || false;
|
||||
|
||||
const inputRefs = [
|
||||
useRef<HTMLIonInputElement>(null),
|
||||
useRef<HTMLIonInputElement>(null),
|
||||
useRef<HTMLIonInputElement>(null),
|
||||
useRef<HTMLIonInputElement>(null),
|
||||
useRef<HTMLIonInputElement>(null),
|
||||
useRef<HTMLIonInputElement>(null),
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!email) {
|
||||
history.replace('/auth');
|
||||
}
|
||||
if (resent) {
|
||||
setCooldown(60);
|
||||
}
|
||||
}, [email, resent, history]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldown > 0) {
|
||||
const timer = setTimeout(() => setCooldown(cooldown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [cooldown]);
|
||||
|
||||
const handleDigitChange = (index: number, value: string) => {
|
||||
const sanitized = value.replace(/[^0-9]/g, '').slice(-1);
|
||||
const newDigits = [...digits];
|
||||
newDigits[index] = sanitized;
|
||||
setDigits(newDigits);
|
||||
|
||||
if (sanitized && index < 5) {
|
||||
inputRefs[index + 1].current?.setFocus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (
|
||||
index: number,
|
||||
e: React.KeyboardEvent<HTMLIonInputElement>
|
||||
) => {
|
||||
if (e.key === 'Backspace') {
|
||||
if (!digits[index] && index > 0) {
|
||||
inputRefs[index - 1].current?.setFocus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyOtp = async () => {
|
||||
const code = digits.join('');
|
||||
if (code.length !== 6) {
|
||||
setError('Please enter all 6 digits');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
email,
|
||||
token: code,
|
||||
type: 'email',
|
||||
});
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setVerified(true);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
if (cooldown > 0) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { error } = await supabase.auth.resend({ type: 'signup', email });
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setCooldown(60);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const goBackToAuth = () => {
|
||||
history.replace('/auth');
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage style={{ background: '#f6f1e8' }}>
|
||||
<IonContent
|
||||
style={{
|
||||
'--background': 'linear-gradient(180deg, #f9f5ee 0%, #f3ede3 100%)',
|
||||
'--padding-start': '0px',
|
||||
'--padding-end': '0px',
|
||||
'--padding-top': '0px',
|
||||
'--padding-bottom': '0px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding:
|
||||
'calc(var(--ion-safe-area-top, 0px) + 18px) 20px calc(var(--ion-safe-area-bottom, 0px) + 28px)',
|
||||
minHeight: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'transparent',
|
||||
borderRadius: '0px',
|
||||
padding: '0px',
|
||||
margin: '0 0 24px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Check your email
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
We sent a 6-digit code to {email}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!verified ? (
|
||||
<>
|
||||
<OtpInputRow
|
||||
digits={digits}
|
||||
onChange={handleDigitChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
inputRefs={inputRefs}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleVerifyOtp}
|
||||
disabled={loading}
|
||||
style={{
|
||||
background: '#2f6b4f',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '16px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: '#ffffff',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 0 16px 0',
|
||||
}}
|
||||
>
|
||||
{loading ? <IonSpinner name="dots" color="light" /> : 'Verify'}
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
onClick={handleResend}
|
||||
disabled={cooldown > 0 || loading}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '10px 14px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: cooldown > 0 ? '#9aa3a0' : '#2f6b4f',
|
||||
cursor: cooldown > 0 ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(190, 60, 60, 0.10)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 14px',
|
||||
margin: '16px 0 0 0',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: '#a33b32',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '24px',
|
||||
padding: '24px',
|
||||
margin: '24px 0 0 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<IonIcon
|
||||
icon={checkmarkCircleOutline}
|
||||
style={{ fontSize: '36px', color: '#2f6b4f' }}
|
||||
/>
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: '700',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Email confirmed
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
You can now sign in to your account.
|
||||
</p>
|
||||
<button
|
||||
onClick={goBackToAuth}
|
||||
style={{
|
||||
background: '#2f6b4f',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '16px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: '#ffffff',
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
marginTop: '12px',
|
||||
}}
|
||||
>
|
||||
Back to Sign In
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyEmail;
|
||||
+2
-2
@@ -2,6 +2,6 @@ import { createClient } from '@supabase/supabase-js';
|
||||
import type { Database } from './database.types';
|
||||
|
||||
export const supabase = createClient<Database>(
|
||||
'https://zillycfjfbzofltgyjwy.supabase.co',
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InppbGx5Y2ZqZmJ6b2ZsdGd5and5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkxMjYzODQsImV4cCI6MjA5NDcwMjM4NH0.BB4ccBEQI2krc7SseC73234Nfya-92VGcTELNQdzt50',
|
||||
'https://kxejpmyjfdrbaupauexc.supabase.co',
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt4ZWpwbXlqZmRyYmF1cGF1ZXhjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODM2OTkzMzksImV4cCI6MjA5OTI3NTMzOX0.FteANsPY4x605R26bxC6sR4iiYbw_nwG4zrcDYEH83Y',
|
||||
);
|
||||
|
||||
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
// Ambient test globals for optional *.test.* files in the editor / tsc.
|
||||
// No test runner is installed in the template; stubs avoid spurious TS errors.
|
||||
|
||||
interface Matchers<R = void> {
|
||||
toBeDefined(): R;
|
||||
toBe(expected: unknown): R;
|
||||
toEqual(expected: unknown): R;
|
||||
toBeTruthy(): R;
|
||||
toBeFalsy(): R;
|
||||
toBeNull(): R;
|
||||
toContain(item: unknown): R;
|
||||
toHaveLength(length: number): R;
|
||||
toMatch(expected: string | RegExp): R;
|
||||
toThrow(expected?: string | RegExp | Error): R;
|
||||
not: Matchers<R>;
|
||||
}
|
||||
|
||||
interface Expect {
|
||||
<T = unknown>(actual: T): Matchers;
|
||||
}
|
||||
|
||||
declare const expect: Expect;
|
||||
|
||||
type TestCaseFn = (
|
||||
name: string,
|
||||
fn?: () => void | Promise<void>,
|
||||
timeout?: number,
|
||||
) => void;
|
||||
|
||||
declare const test: TestCaseFn;
|
||||
declare const it: TestCaseFn;
|
||||
declare const describe: TestCaseFn;
|
||||
|
||||
declare function beforeEach(fn: () => void | Promise<void>): void;
|
||||
declare function afterEach(fn: () => void | Promise<void>): void;
|
||||
declare function beforeAll(fn: () => void | Promise<void>): void;
|
||||
declare function afterAll(fn: () => void | Promise<void>): void;
|
||||
|
||||
declare module "@testing-library/react" {
|
||||
export function render(
|
||||
ui: unknown,
|
||||
options?: object,
|
||||
): {
|
||||
baseElement: Element;
|
||||
container: Element;
|
||||
unmount(): void;
|
||||
rerender(ui: unknown): void;
|
||||
};
|
||||
export const screen: Record<string, (...args: unknown[]) => Element>;
|
||||
export function cleanup(): void;
|
||||
}
|
||||
+58
-901
@@ -1,916 +1,73 @@
|
||||
/* Ionic Variables and Color Palette */
|
||||
|
||||
:root {
|
||||
--ion-color-primary: #6d28d9;
|
||||
--ion-color-primary-rgb: 109, 40, 217;
|
||||
--ion-color-primary: #7b2d26;
|
||||
--ion-color-primary-rgb: 123, 45, 38;
|
||||
--ion-color-primary-contrast: #ffffff;
|
||||
--ion-color-primary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-primary-shade: #5b21b6;
|
||||
--ion-color-primary-tint: #7c3aed;
|
||||
--ion-color-primary-shade: #6c2721;
|
||||
--ion-color-primary-tint: #883f3a;
|
||||
|
||||
--ion-color-secondary: #0f766e;
|
||||
--ion-color-secondary-rgb: 15, 118, 110;
|
||||
--ion-color-secondary: #b07a3a;
|
||||
--ion-color-secondary-rgb: 176, 122, 58;
|
||||
--ion-color-secondary-contrast: #ffffff;
|
||||
--ion-color-secondary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-secondary-shade: #115e59;
|
||||
--ion-color-secondary-tint: #14b8a6;
|
||||
--ion-color-secondary-shade: #9b6b33;
|
||||
--ion-color-secondary-tint: #b8894e;
|
||||
|
||||
--ion-color-tertiary: #ea580c;
|
||||
--ion-color-tertiary-rgb: 234, 88, 12;
|
||||
--ion-color-tertiary-contrast: #ffffff;
|
||||
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-tertiary-shade: #c2410c;
|
||||
--ion-color-tertiary-tint: #f97316;
|
||||
--ion-background-color: #f8f4ef;
|
||||
--color-bg: #f8f4ef;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-raised: #f2ece4;
|
||||
--color-brand: #7b2d26;
|
||||
--color-brand-soft: rgba(123, 45, 38, 0.1);
|
||||
--color-brand-medium: rgba(123, 45, 38, 0.18);
|
||||
|
||||
--ion-color-success: #16a34a;
|
||||
--ion-color-success-rgb: 22, 163, 74;
|
||||
--ion-color-success-contrast: #ffffff;
|
||||
--ion-color-success-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-success-shade: #15803d;
|
||||
--ion-color-success-tint: #22c55e;
|
||||
--ion-text-color: #2f221d;
|
||||
--ion-text-color-rgb: 47, 34, 29;
|
||||
--color-text-primary: #2f221d;
|
||||
--color-text-secondary: rgba(47, 34, 29, 0.64);
|
||||
--color-text-tertiary: rgba(47, 34, 29, 0.42);
|
||||
--color-border: rgba(47, 34, 29, 0.08);
|
||||
|
||||
--ion-color-warning: #f59e0b;
|
||||
--ion-color-warning-rgb: 245, 158, 11;
|
||||
--ion-color-warning-contrast: #1f2937;
|
||||
--ion-color-warning-contrast-rgb: 31, 41, 55;
|
||||
--ion-color-warning-shade: #d97706;
|
||||
--ion-color-warning-tint: #fbbf24;
|
||||
|
||||
--ion-color-danger: #dc2626;
|
||||
--ion-color-danger-rgb: 220, 38, 38;
|
||||
--ion-color-danger-contrast: #ffffff;
|
||||
--ion-color-danger-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-danger-shade: #b91c1c;
|
||||
--ion-color-danger-tint: #ef4444;
|
||||
|
||||
--ion-color-dark: #111827;
|
||||
--ion-color-dark-rgb: 17, 24, 39;
|
||||
--ion-color-dark-contrast: #ffffff;
|
||||
--ion-color-dark-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-dark-shade: #030712;
|
||||
--ion-color-dark-tint: #374151;
|
||||
|
||||
--ion-color-medium: #6b7280;
|
||||
--ion-color-medium-rgb: 107, 114, 128;
|
||||
--ion-color-medium-contrast: #ffffff;
|
||||
--ion-color-medium-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-medium-shade: #4b5563;
|
||||
--ion-color-medium-tint: #9ca3af;
|
||||
|
||||
--ion-color-light: #f3f4f6;
|
||||
--ion-color-light-rgb: 243, 244, 246;
|
||||
--ion-color-light-contrast: #111827;
|
||||
--ion-color-light-contrast-rgb: 17, 24, 39;
|
||||
--ion-color-light-shade: #e5e7eb;
|
||||
--ion-color-light-tint: #f9fafb;
|
||||
|
||||
--ion-background-color: #f6f7fb;
|
||||
--ion-text-color: #111827;
|
||||
--ion-border-color: #e5e7eb;
|
||||
--ion-color-success: #7b2d26;
|
||||
--ion-color-warning: #d29a3f;
|
||||
--ion-color-danger: #a33b32;
|
||||
--ion-color-medium: #8e7d73;
|
||||
--ion-color-light: #f8f4ef;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--ion-background-color: #201513;
|
||||
--color-bg: #201513;
|
||||
--color-surface: #2b1c19;
|
||||
--color-surface-raised: #3a2723;
|
||||
--color-brand: #c7865f;
|
||||
--color-brand-soft: rgba(199, 134, 95, 0.14);
|
||||
--color-brand-medium: rgba(199, 134, 95, 0.22);
|
||||
|
||||
--ion-text-color: #f5ebe3;
|
||||
--ion-text-color-rgb: 245, 235, 227;
|
||||
--color-text-primary: #f5ebe3;
|
||||
--color-text-secondary: rgba(245, 235, 227, 0.68);
|
||||
--color-text-tertiary: rgba(245, 235, 227, 0.44);
|
||||
|
||||
--ion-color-primary: #c7865f;
|
||||
--ion-color-primary-rgb: 199, 134, 95;
|
||||
--ion-color-primary-contrast: #201513;
|
||||
--ion-color-primary-contrast-rgb: 32, 21, 19;
|
||||
--ion-color-primary-shade: #b27754;
|
||||
--ion-color-primary-tint: #cd9270;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top left,
|
||||
rgba(168, 85, 247, 0.12),
|
||||
transparent 34%
|
||||
),
|
||||
linear-gradient(180deg, #fbf7ff 0%, #f2ecfb 100%);
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
ion-toolbar {
|
||||
--background: #ffffff;
|
||||
--color: var(--ion-color-dark);
|
||||
}
|
||||
|
||||
.mono-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dashboard-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.habit-card,
|
||||
.stats-card,
|
||||
.empty-state-card,
|
||||
.inline-message,
|
||||
.auth-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #ebeef4;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 8px 24px rgba(17, 24, 39, 0.04);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.hero-card.compact {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-block;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ion-color-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hero-card h1,
|
||||
.auth-hero h1,
|
||||
.empty-state-card h2 {
|
||||
margin: 0;
|
||||
font-size: 1.9rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--ion-color-dark);
|
||||
}
|
||||
|
||||
.hero-card p,
|
||||
.auth-hero p,
|
||||
.inline-message p,
|
||||
.empty-state-card p,
|
||||
.stats-card p,
|
||||
.habit-copy p {
|
||||
color: var(--ion-color-medium);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.stack-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.habit-card {
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.habit-card-main {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.habit-color-pill {
|
||||
width: 10px;
|
||||
border-radius: 999px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.habit-copy {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.habit-copy h3,
|
||||
.stats-card h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-dark);
|
||||
}
|
||||
|
||||
.habit-metrics-row,
|
||||
.stats-card-footer,
|
||||
.stats-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.habit-metrics-row span,
|
||||
.stats-card-footer span,
|
||||
.stats-card-header p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--ion-color-medium);
|
||||
}
|
||||
|
||||
.habit-metrics-row ion-icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.habit-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.empty-state-card,
|
||||
.inline-message {
|
||||
padding: 28px 22px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 14px;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(109, 40, 217, 0.1);
|
||||
color: var(--ion-color-primary);
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
min-height: 100%;
|
||||
padding: 32px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.auth-hero {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-card ion-card-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-form,
|
||||
.auth-message-block,
|
||||
.habit-form-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.auth-input-item {
|
||||
--background: #f8fafc;
|
||||
--border-radius: 18px;
|
||||
}
|
||||
|
||||
.auth-feedback {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.auth-divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: rgba(107, 114, 128, 0.22);
|
||||
}
|
||||
|
||||
.auth-divider span {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 0 12px;
|
||||
background: #ffffff;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ion-color-medium);
|
||||
}
|
||||
|
||||
.google-auth-button,
|
||||
.apple-auth-button {
|
||||
--border-radius: 18px;
|
||||
--border-color: rgba(17, 24, 39, 0.08);
|
||||
--background: #ffffff;
|
||||
--background-hover: #ffffff;
|
||||
--background-activated: #f8fafc;
|
||||
--color: var(--ion-color-dark);
|
||||
--box-shadow: none;
|
||||
font-weight: 700;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.apple-auth-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--ion-color-danger);
|
||||
}
|
||||
|
||||
.color-label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.habit-color-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.habit-color-dot {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid transparent;
|
||||
}
|
||||
|
||||
.habit-color-dot.selected {
|
||||
border-color: #111827;
|
||||
}
|
||||
|
||||
.stats-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.stats-streak-badge {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.home-header ion-toolbar {
|
||||
--background: transparent;
|
||||
--border-color: transparent;
|
||||
--padding-start: 12px;
|
||||
--padding-end: 12px;
|
||||
}
|
||||
|
||||
.home-content {
|
||||
--background: transparent;
|
||||
}
|
||||
|
||||
.home-shell {
|
||||
padding-top: calc(var(--ion-safe-area-top) + 10px);
|
||||
padding-bottom: 32px;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.soft-icon-button {
|
||||
--background: rgba(255, 255, 255, 0.86);
|
||||
--background-hover: rgba(255, 255, 255, 0.96);
|
||||
--background-activated: rgba(255, 255, 255, 0.96);
|
||||
--border-radius: 999px;
|
||||
--box-shadow: 0 10px 24px rgba(109, 40, 217, 0.08);
|
||||
--color: #17121f;
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
--padding-top: 0;
|
||||
--padding-bottom: 0;
|
||||
width: 46px;
|
||||
min-width: 46px;
|
||||
height: 46px;
|
||||
min-height: 46px;
|
||||
border-radius: 999px;
|
||||
margin-inline-start: 6px;
|
||||
}
|
||||
|
||||
.soft-icon-button::part(native) {
|
||||
width: 46px;
|
||||
min-width: 46px;
|
||||
height: 46px;
|
||||
min-height: 46px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.habits-hero {
|
||||
padding: 6px 4px 0;
|
||||
}
|
||||
|
||||
.habits-hero-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
color: rgba(34, 23, 54, 0.92);
|
||||
}
|
||||
|
||||
.hero-title-wrap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-title-wrap h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(3rem, 8vw, 4.2rem);
|
||||
line-height: 0.94;
|
||||
letter-spacing: -0.08em;
|
||||
font-weight: 800;
|
||||
color: #110d18;
|
||||
}
|
||||
|
||||
.hero-title-wrap span {
|
||||
font-size: clamp(2.2rem, 7vw, 3.5rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.08em;
|
||||
font-weight: 700;
|
||||
color: rgba(26, 16, 38, 0.32);
|
||||
}
|
||||
|
||||
.hero-add-button {
|
||||
--background: rgba(255, 255, 255, 0.86);
|
||||
--background-hover: rgba(255, 255, 255, 0.96);
|
||||
--border-radius: 999px;
|
||||
--box-shadow: 0 14px 30px rgba(109, 40, 217, 0.1);
|
||||
--color: var(--ion-color-primary);
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
--padding-top: 0;
|
||||
--padding-bottom: 0;
|
||||
width: 52px;
|
||||
min-width: 52px;
|
||||
height: 52px;
|
||||
min-height: 52px;
|
||||
border-radius: 999px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.hero-add-button::part(native) {
|
||||
width: 52px;
|
||||
min-width: 52px;
|
||||
height: 52px;
|
||||
min-height: 52px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.hero-progress-track {
|
||||
margin-top: 20px;
|
||||
height: 12px;
|
||||
background: rgba(109, 40, 217, 0.12);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-progress-fill {
|
||||
height: 100%;
|
||||
min-width: 12px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #5b21b6 0%, #c084fc 100%);
|
||||
position: relative;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.hero-progress-dot {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.hero-progress-meta {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: rgba(43, 31, 58, 0.74);
|
||||
}
|
||||
|
||||
.home-routine-panel,
|
||||
.home-summary-card,
|
||||
.home-inline-card,
|
||||
.home-empty-state-card,
|
||||
.habit-card--editorial {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||
box-shadow: 0 18px 40px rgba(106, 62, 149, 0.08);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.home-routine-panel {
|
||||
padding: 18px;
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
.home-habit-stack {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.habit-card--editorial {
|
||||
border-radius: 24px;
|
||||
padding: 14px 12px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.habit-card--editorial.is-complete {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.habit-check-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(109, 40, 217, 0.08);
|
||||
color: rgba(109, 40, 217, 0.72);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.habit-check-button.is-complete {
|
||||
background: rgba(109, 40, 217, 0.16);
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
|
||||
.habit-copy-topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.habit-copy-topline .habit-color-pill {
|
||||
width: 10px;
|
||||
min-height: 10px;
|
||||
height: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.habit-card-tap-area {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.habit-card--editorial .habit-card-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.habit-card--editorial .habit-copy h3 {
|
||||
margin: 0;
|
||||
font-size: 1.03rem;
|
||||
}
|
||||
|
||||
.habit-card--editorial .habit-copy p {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
color: rgba(52, 41, 66, 0.7);
|
||||
}
|
||||
|
||||
.habit-card--editorial .habit-metrics-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.habit-card--editorial .habit-metrics-row span {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.habit-delete-button {
|
||||
--color: rgba(86, 68, 106, 0.72);
|
||||
--padding-start: 6px;
|
||||
--padding-end: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.habit-card-skeleton {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.habit-card-skeleton ion-skeleton-text {
|
||||
margin: 0;
|
||||
--background: rgba(109, 40, 217, 0.08);
|
||||
--background-rgb: 109, 40, 217;
|
||||
}
|
||||
|
||||
.habit-skeleton-check,
|
||||
.habit-skeleton-action {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.habit-skeleton-title {
|
||||
width: min(42vw, 160px);
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.habit-skeleton-color-pill {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
min-width: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.habit-skeleton-description {
|
||||
width: min(58vw, 220px);
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.habit-skeleton-metric {
|
||||
width: 84px;
|
||||
height: 13px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.habit-skeleton-metric.short {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.home-complete-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.home-section-label {
|
||||
margin: 0 4px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: rgba(66, 50, 84, 0.74);
|
||||
}
|
||||
|
||||
.home-summary-card {
|
||||
border-radius: 30px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-ring {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.summary-ring-inner {
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
color: #1d1430;
|
||||
}
|
||||
|
||||
.summary-copy h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.summary-copy p {
|
||||
margin: 0;
|
||||
color: rgba(52, 41, 66, 0.7);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.summary-metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.summary-metrics span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: rgba(42, 30, 56, 0.78);
|
||||
}
|
||||
|
||||
.home-empty-state-card {
|
||||
border-radius: 26px;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.home-empty-state-button {
|
||||
--border-radius: 999px;
|
||||
--box-shadow: none;
|
||||
}
|
||||
|
||||
.account-shell {
|
||||
gap: 20px;
|
||||
padding-bottom: calc(24px + var(--ion-safe-area-bottom));
|
||||
}
|
||||
|
||||
.account-hero-card {
|
||||
overflow: hidden;
|
||||
border-radius: 30px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 16px 38px rgba(71, 45, 108, 0.08);
|
||||
}
|
||||
|
||||
.account-hero-image-wrap {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 10;
|
||||
background: #e9e1f6;
|
||||
}
|
||||
|
||||
.account-hero-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.account-hero-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(17, 24, 39, 0) 0%,
|
||||
rgba(17, 24, 39, 0.3) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.account-hero-content {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.account-hero-content h1 {
|
||||
margin: 0;
|
||||
font-size: 1.55rem;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--ion-color-dark);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.account-hero-content p {
|
||||
margin: 0;
|
||||
color: rgba(55, 65, 81, 0.76);
|
||||
}
|
||||
|
||||
.account-highlight-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-highlight-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(109, 40, 217, 0.1);
|
||||
color: var(--ion-color-primary);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.account-highlight-chip.subtle {
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
color: var(--ion-color-secondary);
|
||||
}
|
||||
|
||||
.account-highlight-chip ion-icon,
|
||||
.account-highlight-chip svg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.account-details-card {
|
||||
gap: 18px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.account-info-item {
|
||||
--background: #f7f5fb;
|
||||
--border-radius: 20px;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
--inner-padding-end: 0px;
|
||||
}
|
||||
|
||||
.account-info-item h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
|
||||
.account-info-item p {
|
||||
margin: 0;
|
||||
color: var(--ion-color-dark);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.account-trust-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.account-trust-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
background: rgba(22, 163, 74, 0.12);
|
||||
color: var(--ion-color-success);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.account-trust-row h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-dark);
|
||||
}
|
||||
|
||||
.account-trust-row p {
|
||||
margin: 0;
|
||||
color: rgba(55, 65, 81, 0.74);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.account-primary-button,
|
||||
.account-secondary-button {
|
||||
--border-radius: 18px;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.account-primary-button {
|
||||
--background: var(--ion-color-primary);
|
||||
--box-shadow: none;
|
||||
}
|
||||
|
||||
.account-secondary-button {
|
||||
--border-width: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.home-summary-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.account-hero-image-wrap {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
.page-shell {
|
||||
padding: calc(var(--ion-safe-area-top, 0px) + 18px) 20px
|
||||
calc(var(--ion-safe-area-bottom, 0px) + 28px);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export interface EventCategory {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
accent_color: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ChurchEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category_id: string;
|
||||
audience: 'all' | 'youth' | 'children';
|
||||
venue: string;
|
||||
start_at: string;
|
||||
end_at: string | null;
|
||||
day_label: string;
|
||||
is_featured: boolean;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
event_categories?: EventCategory; // Relation
|
||||
}
|
||||
|
||||
export interface NewsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
image_url: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { StatusBar, Style } from '@capacitor/status-bar';
|
||||
export { Style };
|
||||
|
||||
``
|
||||
const _origin = (() => {
|
||||
try { if (document.referrer) return new URL(document.referrer).origin; } catch {}
|
||||
return window.location.origin;
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
|
||||
+17
-3
@@ -1,3 +1,6 @@
|
||||
// NOTE: @ionic/react and @ionic/react-router are pinned to 8.8.7 in package.json.
|
||||
// 8.8.8 introduced a regression that breaks IonRouterOutlet page transition animations.
|
||||
// Do not upgrade until the upstream fix is confirmed.
|
||||
import { defineConfig, type Plugin } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
@@ -5,16 +8,27 @@ const injectRouterBasename: Plugin = {
|
||||
name: 'inject-router-basename',
|
||||
enforce: 'pre',
|
||||
transform(code, id) {
|
||||
if (!/\.[jt]sx$/.test(id)) return;
|
||||
return code.replace(
|
||||
if (!/\.[jt]sx$/.test(id)) return null;
|
||||
const next = code.replace(
|
||||
/<IonReactRouter(?![^>]*basename)([^>]*)>/g,
|
||||
'<IonReactRouter$1 basename={import.meta.env.BASE_URL}>',
|
||||
);
|
||||
return next === code ? null : next;
|
||||
},
|
||||
};
|
||||
|
||||
const hideScrollbars: Plugin = {
|
||||
name: 'hide-scrollbars',
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(
|
||||
'</head>',
|
||||
'<style>*{scrollbar-width:none!important;-ms-overflow-style:none!important}*::-webkit-scrollbar{display:none!important}</style></head>',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [injectRouterBasename, react()],
|
||||
plugins: [injectRouterBasename, hideScrollbars, react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 8100,
|
||||
|
||||
Reference in New Issue
Block a user