Compare commits

..

1 Commits

Author SHA1 Message Date
AppCakes c8ec54c22d build: da8aa2fb-9871-4e3e-a583-70eda77c4d68 2026-06-12 11:55:41 +00:00
49 changed files with 4316 additions and 4897 deletions
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@ android {
namespace = "io.ionic.starter" namespace = "io.ionic.starter"
compileSdk = rootProject.ext.compileSdkVersion compileSdk = rootProject.ext.compileSdkVersion
defaultConfig { defaultConfig {
applicationId "com.afmeaglesnzw.app" applicationId "io.shopkeep.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
+1 -1
View File
@@ -25,7 +25,7 @@
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.afmeaglesnzw.app" /> <data android:scheme="io.shopkeep.app" />
</intent-filter> </intent-filter>
</activity> </activity>
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<resources> <resources>
<string name="app_name">AFM.EAGLESNZW</string> <string name="app_name">ShopKeep</string>
<string name="title_activity_main">AFM.EAGLESNZW</string> <string name="title_activity_main">ShopKeep</string>
<string name="package_name">io.ionic.starter</string> <string name="package_name">io.ionic.starter</string>
<string name="custom_url_scheme">io.ionic.starter</string> <string name="custom_url_scheme">io.ionic.starter</string>
</resources> </resources>
+2 -2
View File
@@ -1,8 +1,8 @@
import type { CapacitorConfig } from '@capacitor/cli'; import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = { const config: CapacitorConfig = {
appId: 'com.afmeaglesnzw.app', appId: 'io.shopkeep.app',
appName: 'AFM.EAGLESNZW', appName: 'ShopKeep',
webDir: 'dist', webDir: 'dist',
plugins: { plugins: {
FirebaseAuthentication: { FirebaseAuthentication: {
+2 -2
View File
@@ -308,7 +308,7 @@
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.afmeaglesnzw.app; PRODUCT_BUNDLE_IDENTIFIER = io.shopkeep.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -330,7 +330,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.afmeaglesnzw.app; PRODUCT_BUNDLE_IDENTIFIER = io.shopkeep.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
+3 -7
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>AFM.EAGLESNZW</string> <string>ShopKeep</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -51,16 +51,12 @@
<array> <array>
<dict> <dict>
<key>CFBundleURLName</key> <key>CFBundleURLName</key>
<string>com.afmeaglesnzw.app</string> <string>io.shopkeep.app</string>
<key>CFBundleURLSchemes</key> <key>CFBundleURLSchemes</key>
<array> <array>
<string>com.afmeaglesnzw.app</string> <string>io.shopkeep.app</string>
</array> </array>
</dict> </dict>
</array> </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> </dict>
</plist> </plist>
+1 -2
View File
@@ -18,14 +18,13 @@
"@capacitor/haptics": "8.0.2", "@capacitor/haptics": "8.0.2",
"@capacitor/ios": "8.3.4", "@capacitor/ios": "8.3.4",
"@capacitor/keyboard": "8.0.3", "@capacitor/keyboard": "8.0.3",
"@capacitor/preferences": "^8.0.1",
"@capacitor/push-notifications": "^8.1.1", "@capacitor/push-notifications": "^8.1.1",
"@capacitor/status-bar": "8.0.2", "@capacitor/status-bar": "8.0.2",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@ionic/pwa-elements": "^3.0.0", "@ionic/pwa-elements": "^3.0.0",
"@ionic/react": "8.8.7", "@ionic/react": "8.8.7",
"@ionic/react-router": "8.8.7", "@ionic/react-router": "8.8.7",
"@supabase/supabase-js": "^2.108.1", "@supabase/supabase-js": "^2.0.0",
"firebase": "^11.0.0", "firebase": "^11.0.0",
"ionicons": "^7.4.0", "ionicons": "^7.4.0",
"katex": "^0.16.0", "katex": "^0.16.0",
+76 -87
View File
@@ -2,22 +2,40 @@ import React, { useEffect } from 'react';
import { Redirect, Route } from 'react-router-dom'; import { Redirect, Route } from 'react-router-dom';
import { import {
IonApp, IonApp,
IonIcon,
IonLabel,
IonRouterOutlet, IonRouterOutlet,
IonTabs,
IonTabBar, IonTabBar,
IonTabButton, IonTabButton,
IonTabs, IonIcon,
IonLabel,
setupIonicReact, setupIonicReact,
} from '@ionic/react'; } from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router'; import { IonReactRouter } from '@ionic/react-router';
import { import {
homeOutline, homeOutline,
calendarOutline, peopleOutline,
personOutline, receiptOutline,
newspaperOutline, home,
people,
receipt,
} from 'ionicons/icons'; } from 'ionicons/icons';
import Home from './pages/Home';
import CustomersPage from './pages/CustomersPage';
import CustomerDetailPage from './pages/CustomerDetailPage';
import HistoryPage from './pages/HistoryPage';
import SaleFormPage from './pages/SaleFormPage';
import CreditFormPage from './pages/CreditFormPage';
import RepaymentFormPage from './pages/RepaymentFormPage';
import AuthPage from './pages/AuthPage';
import VerifyEmailPage from './pages/VerifyEmailPage';
import ForgotPasswordPage from './pages/ForgotPasswordPage';
import NewPasswordPage from './pages/NewPasswordPage';
import { AuthProvider } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import { setStatusBarStyle, Style } from './utils/statusBar';
/* Core CSS required for Ionic components to work properly */ /* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css'; import '@ionic/react/css/core.css';
@@ -34,36 +52,25 @@ import '@ionic/react/css/text-transformation.css';
import '@ionic/react/css/flex-utils.css'; import '@ionic/react/css/flex-utils.css';
import '@ionic/react/css/display.css'; import '@ionic/react/css/display.css';
/**
* Ionic Dark Mode
* -----------------------------------------------------
* For more info, please see:
* https://ionicframework.com/docs/theming/dark-mode
*/
/* import '@ionic/react/css/palettes/dark.always.css'; */
/* import '@ionic/react/css/palettes/dark.class.css'; */
import '@ionic/react/css/palettes/dark.system.css';
/* Theme variables */ /* Theme variables */
import './theme/variables.css'; import './theme/variables.css';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
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(); setupIonicReact();
const SetupProfileRoute: React.FC = () => {
const { user } = useAuth();
if (!user) return <Redirect to="/auth" />;
return <SetupProfile />;
};
const App: React.FC = () => { const App: React.FC = () => {
useEffect(() => { useEffect(() => {
// Set status bar to light icons on our dark-ish backgrounds
setStatusBarStyle(Style.Dark); setStatusBarStyle(Style.Dark);
}, []); }, []);
@@ -72,80 +79,64 @@ const App: React.FC = () => {
<AuthProvider> <AuthProvider>
<IonReactRouter> <IonReactRouter>
<IonRouterOutlet> <IonRouterOutlet>
{/* Unprotected Routes */} {/* Auth Routes */}
<Route path="/auth" component={Auth} exact /> <Route path="/auth" component={AuthPage} exact />
<Route path="/verify-email" component={VerifyEmail} exact /> <Route path="/verify-email" component={VerifyEmailPage} exact />
<Route
path="/forgot-password"
component={ForgotPasswordPage}
exact
/>
<Route path="/new-password" component={NewPasswordPage} exact />
{/* Profile Setup (requires user, but no profile yet) */} {/* Protected Form & Detail Routes (Outer Outlet for push/pop animation) */}
<Route path="/setup-profile" component={SetupProfileRoute} exact /> <ProtectedRoute path="/sale/new" component={SaleFormPage} exact />
{/* Non-tab Protected Routes */}
<ProtectedRoute <ProtectedRoute
path="/event/:id([0-9a-fA-F-]{36})" path="/credit/new"
component={EventDetail} component={CreditFormPage}
exact exact
/> />
<ProtectedRoute <ProtectedRoute
path="/admin/events/new" path="/repayment/new"
component={AdminEventForm} component={RepaymentFormPage}
exact exact
/> />
<ProtectedRoute <ProtectedRoute
path="/admin/events/:id([0-9a-fA-F-]{36})/edit" path="/customers/:id([0-9a-fA-F-]{36})"
component={AdminEventForm} component={CustomerDetailPage}
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 exact
/> />
{/* Tabs */} {/* Tab Shell */}
<ProtectedRoute <Route
path={['/home', '/events', '/news', '/profile']} path={['/home', '/customers', '/history']}
component={() => ( render={() => (
<IonTabs> <IonTabs>
<IonRouterOutlet> <IonRouterOutlet>
<Route path="/home" component={Home} exact /> <ProtectedRoute path="/home" component={Home} exact />
<Route path="/events" component={Events} exact /> <ProtectedRoute
<Route path="/news" component={News} exact /> path="/customers"
<Route path="/profile" component={Profile} exact /> component={CustomersPage}
exact
/>
<ProtectedRoute
path="/history"
component={HistoryPage}
exact
/>
</IonRouterOutlet> </IonRouterOutlet>
<IonTabBar <IonTabBar slot="bottom">
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"> <IonTabButton tab="home" href="/home">
<IonIcon icon={homeOutline} /> <IonIcon icon={homeOutline} />
<IonLabel>Home</IonLabel> <IonLabel>Home</IonLabel>
</IonTabButton> </IonTabButton>
<IonTabButton tab="events" href="/events"> <IonTabButton tab="customers" href="/customers">
<IonIcon icon={calendarOutline} /> <IonIcon icon={peopleOutline} />
<IonLabel>Events</IonLabel> <IonLabel>Customers</IonLabel>
</IonTabButton> </IonTabButton>
<IonTabButton tab="news" href="/news"> <IonTabButton tab="history" href="/history">
<IonIcon icon={newspaperOutline} /> <IonIcon icon={receiptOutline} />
<IonLabel>News</IonLabel> <IonLabel>History</IonLabel>
</IonTabButton>
<IonTabButton tab="profile" href="/profile">
<IonIcon icon={personOutline} />
<IonLabel>Profile</IonLabel>
</IonTabButton> </IonTabButton>
</IonTabBar> </IonTabBar>
</IonTabs> </IonTabs>
@@ -153,9 +144,7 @@ const App: React.FC = () => {
/> />
{/* Root Redirect */} {/* Root Redirect */}
<Route exact path="/"> <Route exact path="/" render={() => <Redirect to="/home" />} />
<Redirect to="/auth" />
</Route>
</IonRouterOutlet> </IonRouterOutlet>
</IonReactRouter> </IonReactRouter>
</AuthProvider> </AuthProvider>
+130
View File
@@ -0,0 +1,130 @@
import React from 'react';
import { IonIcon, IonItem, IonLabel, IonNote } from '@ionic/react';
import {
receiptOutline,
personAddOutline,
cashOutline,
chevronForwardOutline,
} from 'ionicons/icons';
import { formatMoney } from '../utils/money';
import { useAuth } from '../contexts/AuthContext';
import { formatDisplayDate } from '../utils/dates';
export type ActivityType = 'sale' | 'credit' | 'repayment';
export interface ActivityItem {
id: string;
type: ActivityType;
amount: number;
date: string;
customerName?: string;
note?: string;
customerId?: string;
}
interface ActivityRowProps {
item: ActivityItem;
onClick?: () => void;
showDate?: boolean;
}
const ActivityRow: React.FC<ActivityRowProps> = ({
item,
onClick,
showDate = false,
}) => {
const { profile } = useAuth();
const currency = profile?.currency || 'USD';
const getIcon = () => {
switch (item.type) {
case 'sale':
return receiptOutline;
case 'credit':
return personAddOutline;
case 'repayment':
return cashOutline;
}
};
const getTitle = () => {
switch (item.type) {
case 'sale':
return 'Cash Sale';
case 'credit':
return `Credit: ${item.customerName || 'Customer'}`;
case 'repayment':
return `Repayment: ${item.customerName || 'Customer'}`;
}
};
const getAmountColor = () => {
switch (item.type) {
case 'sale':
return 'var(--color-text-primary)';
case 'credit':
return 'var(--ion-color-primary)';
case 'repayment':
return 'var(--ion-color-success)';
}
};
return (
<IonItem
lines="none"
onClick={onClick}
detail={false}
style={{
'--background': 'transparent',
'--padding-start': '0',
'--padding-end': '0',
'--inner-padding-end': '0',
cursor: onClick ? 'pointer' : 'default',
}}
>
<div
className="icon-container"
slot="start"
style={{ marginRight: '12px' }}
>
<IonIcon icon={getIcon()} />
</div>
<IonLabel>
<h3 className="semibold" style={{ margin: '0', fontSize: '15px' }}>
{getTitle()}
</h3>
<p className="secondary-text" style={{ margin: '2px 0 0 0' }}>
{showDate ? formatDisplayDate(item.date) : item.note || 'No notes'}
</p>
</IonLabel>
<div
slot="end"
style={{ textAlign: 'right', display: 'flex', alignItems: 'center' }}
>
<IonNote
className="tabular"
style={{
fontSize: '15px',
color: getAmountColor(),
fontWeight: '400',
}}
>
{item.type === 'credit' ? '-' : '+'}
{formatMoney(item.amount, currency)}
</IonNote>
{onClick && (
<IonIcon
icon={chevronForwardOutline}
style={{
fontSize: '14px',
marginLeft: '8px',
color: 'var(--color-text-tertiary)',
}}
/>
)}
</div>
</IonItem>
);
};
export default ActivityRow;
+115
View File
@@ -0,0 +1,115 @@
import React from 'react';
import { IonIcon, IonItem, IonLabel, IonNote } from '@ionic/react';
import { chevronForwardOutline } from 'ionicons/icons';
import { formatMoney } from '../utils/money';
import { useAuth } from '../contexts/AuthContext';
interface CustomerRowProps {
id: string;
name: string;
balance: number;
phone?: string;
onClick: () => void;
}
const CustomerRow: React.FC<CustomerRowProps> = ({
id,
name,
balance,
phone,
onClick,
}) => {
const { profile } = useAuth();
const currency = profile?.currency || 'USD';
const getInitials = (name: string) => {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
return (
<IonItem
lines="none"
onClick={onClick}
detail={false}
style={{
'--background': 'transparent',
'--padding-start': '0',
'--padding-end': '0',
'--inner-padding-end': '0',
cursor: 'pointer',
marginBottom: '8px',
}}
>
<div
className="icon-container bold"
slot="start"
style={{
marginRight: '16px',
width: '44px',
height: '44px',
fontSize: '14px',
color: 'var(--ion-color-primary)',
}}
>
{getInitials(name)}
</div>
<IonLabel>
<h3 className="semibold" style={{ margin: '0', fontSize: '16px' }}>
{name}
</h3>
{phone && (
<p className="secondary-text" style={{ margin: '2px 0 0 0' }}>
{phone}
</p>
)}
</IonLabel>
<div
slot="end"
style={{ textAlign: 'right', display: 'flex', alignItems: 'center' }}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
}}
>
<IonNote
className="tabular"
style={{
fontSize: '16px',
color:
balance > 0
? 'var(--ion-color-danger)'
: 'var(--ion-color-success)',
fontWeight: '400',
}}
>
{formatMoney(balance, currency)}
</IonNote>
<span
className="tertiary-text"
style={{ fontSize: '10px', marginTop: '2px' }}
>
{balance > 0 ? 'OUTSTANDING' : 'CLEARED'}
</span>
</div>
<IonIcon
icon={chevronForwardOutline}
style={{
fontSize: '16px',
marginLeft: '12px',
color: 'var(--color-text-tertiary)',
}}
/>
</div>
</IonItem>
);
};
export default CustomerRow;
+79
View File
@@ -0,0 +1,79 @@
import React from 'react';
import { IonIcon, IonButton } from '@ionic/react';
interface EmptyStateProps {
icon: string;
title: string;
description?: string;
actionText?: string;
onAction?: () => void;
}
const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
description,
actionText,
onAction,
}) => {
return (
<div
className="widget-card"
style={{
padding: '40px 20px',
alignItems: 'center',
textAlign: 'center',
justifyContent: 'center',
}}
>
<div
className="icon-container"
style={{
width: '64px',
height: '64px',
borderRadius: '20px',
marginBottom: '20px',
background: 'var(--color-surface-secondary)',
color: 'var(--color-text-tertiary)',
}}
>
<IonIcon icon={icon} style={{ fontSize: '32px' }} />
</div>
<h3
style={{
margin: '0 0 8px 0',
fontSize: '18px',
fontWeight: '700',
color: 'var(--color-text-primary)',
}}
>
{title}
</h3>
{description && (
<p
className="secondary-text"
style={{ margin: '0 0 24px 0', maxWidth: '240px' }}
>
{description}
</p>
)}
{actionText && onAction && (
<IonButton
onClick={onAction}
shape="round"
fill="solid"
style={{
height: '44px',
'--border-radius': '999px',
fontSize: '14px',
fontWeight: '700',
}}
>
{actionText}
</IonButton>
)}
</div>
);
};
export default EmptyState;
-112
View File
@@ -1,112 +0,0 @@
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;
-100
View File
@@ -1,100 +0,0 @@
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;
-76
View File
@@ -1,76 +0,0 @@
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;
+99
View File
@@ -0,0 +1,99 @@
import React from 'react';
import { IonIcon } from '@ionic/react';
import { formatMoney } from '../utils/money';
import { useAuth } from '../contexts/AuthContext';
interface MetricCardProps {
label: string;
value: number;
icon?: string;
subtitle?: string;
fullWidth?: boolean;
loading?: boolean;
}
const MetricCard: React.FC<MetricCardProps> = ({
label,
value,
icon,
subtitle,
fullWidth = false,
loading = false,
}) => {
const { profile } = useAuth();
const currency = profile?.currency || 'USD';
if (loading) {
return (
<div
className="widget-card"
style={{
width: fullWidth ? '100%' : 'auto',
minHeight: fullWidth ? '160px' : '100px',
}}
>
<div
className="skeleton-text"
style={{
width: '40%',
height: '12px',
background: 'var(--color-surface-secondary)',
borderRadius: '4px',
marginBottom: '12px',
}}
/>
<div
className="skeleton-text"
style={{
width: '70%',
height: '32px',
background: 'var(--color-surface-secondary)',
borderRadius: '8px',
}}
/>
</div>
);
}
return (
<div
className="widget-card"
style={{
width: fullWidth ? '100%' : 'auto',
minHeight: fullWidth ? '160px' : '100px',
justifyContent: 'center',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<div>
<span className="tertiary-text">{label}</span>
<div
className="tabular"
style={{
fontSize: fullWidth ? '34px' : '24px',
fontWeight: '400',
color: 'var(--color-text-primary)',
marginTop: '4px',
}}
>
{formatMoney(value, currency)}
</div>
{subtitle && <div className="secondary-text">{subtitle}</div>}
</div>
{icon && (
<div className="icon-container">
<IonIcon icon={icon} />
</div>
)}
</div>
</div>
);
};
export default MetricCard;
+88
View File
@@ -0,0 +1,88 @@
import React from 'react';
import { IonInput, IonLabel } from '@ionic/react';
import { sanitizeMoneyInput, getCurrencySymbol } from '../utils/money';
import { useAuth } from '../contexts/AuthContext';
interface MoneyInputProps {
value: string;
onValueChange: (value: string) => void;
label?: string;
placeholder?: string;
error?: string;
}
const MoneyInput: React.FC<MoneyInputProps> = ({
value,
onValueChange,
label,
placeholder = '0.00',
error,
}) => {
const { profile } = useAuth();
const symbol = getCurrencySymbol(profile?.currency || 'USD');
const handleChange = (e: CustomEvent) => {
const rawValue = e.detail.value || '';
const sanitized = sanitizeMoneyInput(rawValue);
onValueChange(sanitized);
};
return (
<div className="money-input-container">
{label && (
<IonLabel
position="stacked"
className="semibold"
style={{ marginBottom: '8px', display: 'block', fontSize: '13px' }}
>
{label}
</IonLabel>
)}
<div
className="input-wrapper"
style={{
border: error ? '1px solid var(--ion-color-danger)' : 'none',
}}
>
<span
className="bold"
style={{
marginRight: '8px',
color: 'var(--color-text-primary)',
fontSize: '18px',
}}
>
{symbol}
</span>
<IonInput
type="text"
inputMode="decimal"
value={value}
placeholder={placeholder}
onIonChange={handleChange}
style={{
fontSize: '20px',
fontWeight: '700',
'--padding-top': '0',
'--padding-bottom': '0',
}}
className="tabular"
/>
</div>
{error && (
<IonLabel
color="danger"
style={{
fontSize: '12px',
marginTop: '4px',
display: 'block',
}}
>
{error}
</IonLabel>
)}
</div>
);
};
export default MoneyInput;
-70
View File
@@ -1,70 +0,0 @@
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;
+4 -11
View File
@@ -10,22 +10,15 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
component: Component, component: Component,
...rest ...rest
}) => { }) => {
const { user, profile } = useAuth(); const { user, loading } = useAuth();
return ( return (
<Route <Route
{...rest} {...rest}
render={(props) => { render={(props) => {
if (!user) { if (loading) return null; // AuthProvider already handles spinner, but double safety
return <Redirect to="/auth" />;
} return user ? <Component {...props} /> : <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} />;
}} }}
/> />
); );
+115 -83
View File
@@ -1,109 +1,143 @@
import React, { createContext, useContext, useEffect, useState } from 'react'; import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { User } from '@supabase/supabase-js'; import { User } from '@supabase/supabase-js';
import { supabase } from '../supabase'; import { supabase } from '../supabase';
import { Database } from '../database.types';
import { IonSpinner } from '@ionic/react'; import { IonSpinner } from '@ionic/react';
export interface Profile { type Profile = Database['public']['Tables']['profiles']['Row'];
id: string;
email: string;
full_name: string;
role: string;
ministry_group: string | null;
avatar_url: string | null;
}
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
profile: Profile | null; profile: Profile | null;
loading: boolean;
refreshProfile: () => Promise<void>; refreshProfile: () => Promise<void>;
signOut: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType>({ const AuthContext = createContext<AuthContextType | undefined>(undefined);
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 }> = ({ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
children, children,
}) => { }) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null); const [profile, setProfile] = useState<Profile | null>(null);
const [initializing, setInitializing] = useState(true); const [loading, setLoading] = useState(true);
const fetchProfile = async (userId: string) => { const fetchProfile = useCallback(async (currentUser: User) => {
const cached = await getCachedProfile(); try {
if (cached && cached.id === userId) { const fallbackProfile = {
setProfile(cached); id: currentUser.id,
} full_name:
const { data } = await supabase typeof currentUser.user_metadata?.full_name === 'string'
.from('profiles') ? currentUser.user_metadata.full_name
.select('*') : '',
.eq('id', userId) shop_name: 'My Shop',
.single(); currency: 'USD',
if (data) {
setProfile(data);
await cacheProfile(data);
}
}; };
const signOut = async () => { const { data, error } = await supabase
await supabase.auth.signOut(); .from('profiles')
.upsert(fallbackProfile, { onConflict: 'id', ignoreDuplicates: true })
.select('*')
.single();
if (error) {
console.error('Error fetching profile:', error);
setProfile({
...fallbackProfile,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
return;
}
setProfile(data);
} catch (err) {
console.error('Error fetching profile:', err);
setProfile(null);
}
}, []);
const refreshProfile = async () => {
if (user) {
await fetchProfile(user);
}
}; };
useEffect(() => { useEffect(() => {
// One-shot bootstrap let mounted = true;
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 initialiseSession = async () => {
const timeout = window.setTimeout(() => {
if (mounted) {
console.warn('Auth initialisation timed out; showing app shell.');
setLoading(false);
}
}, 8000);
try {
const { const {
data: { subscription }, data: { session },
} = supabase.auth.onAuthStateChange(async (_event, session) => { error,
} = await supabase.auth.getSession();
if (!mounted) return;
if (error) {
console.error('Error restoring auth session:', error);
}
const currentUser = session?.user ?? null; const currentUser = session?.user ?? null;
setUser(currentUser); setUser(currentUser);
if (currentUser) { if (currentUser) {
await fetchProfile(currentUser.id); await fetchProfile(currentUser);
} else { } else {
setProfile(null); setProfile(null);
await clearCachedProfile();
} }
} catch (err) {
console.error('Error restoring auth session:', err);
if (mounted) {
setUser(null);
setProfile(null);
}
} finally {
window.clearTimeout(timeout);
if (mounted) {
setLoading(false);
}
}
};
void initialiseSession();
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
const currentUser = session?.user ?? null;
setUser(currentUser);
if (currentUser) {
void fetchProfile(currentUser);
} else {
setProfile(null);
}
setLoading(false);
}); });
return () => subscription.unsubscribe(); return () => {
}, []); mounted = false;
subscription.unsubscribe();
};
}, [fetchProfile]);
if (initializing) { if (loading) {
return ( return (
<div <div
style={{ style={{
@@ -111,7 +145,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
height: '100vh', height: '100vh',
backgroundColor: '#f5f7f4', background: 'var(--color-bg, #f0f4ff)',
}} }}
> >
<IonSpinner name="crescent" color="primary" /> <IonSpinner name="crescent" color="primary" />
@@ -120,18 +154,16 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
} }
return ( return (
<AuthContext.Provider <AuthContext.Provider value={{ user, profile, loading, refreshProfile }}>
value={{
user,
profile,
refreshProfile: () =>
user ? fetchProfile(user.id) : Promise.resolve(),
signOut,
}}
>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
}; };
export const useAuth = () => useContext(AuthContext); export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
+109 -91
View File
@@ -14,157 +14,175 @@ export type Database = {
} }
public: { public: {
Tables: { Tables: {
event_categories: { credit_customers: {
Row: { Row: {
accent_color: string | null
created_at: string created_at: string
id: string id: string
name: string name: string
slug: string notes: string | null
phone: string | null
updated_at: string
user_id: string
} }
Insert: { Insert: {
accent_color?: string | null
created_at?: string created_at?: string
id?: string id?: string
name: string name: string
slug: string notes?: string | null
phone?: string | null
updated_at?: string
user_id: string
} }
Update: { Update: {
accent_color?: string | null
created_at?: string created_at?: string
id?: string id?: string
name?: string name?: string
slug?: string notes?: string | null
phone?: string | null
updated_at?: string
user_id?: string
} }
Relationships: [] Relationships: []
} }
events: { credits: {
Row: { Row: {
audience: string amount: number
category_id: string | null
created_at: string created_at: string
created_by: string | null credit_date: string
day_label: string customer_id: string
description: string description: string | null
end_at: string | null
id: string id: string
is_featured: boolean
start_at: string
title: string
updated_at: string updated_at: string
venue: string user_id: string
} }
Insert: { Insert: {
audience?: string amount: number
category_id?: string | null
created_at?: string created_at?: string
created_by?: string | null credit_date?: string
day_label: string customer_id: string
description?: string description?: string | null
end_at?: string | null
id?: string id?: string
is_featured?: boolean
start_at: string
title: string
updated_at?: string updated_at?: string
venue: string user_id: string
} }
Update: { Update: {
audience?: string amount?: number
category_id?: string | null
created_at?: string created_at?: string
created_by?: string | null credit_date?: string
day_label?: string customer_id?: string
description?: string description?: string | null
end_at?: string | null
id?: string id?: string
is_featured?: boolean
start_at?: string
title?: string
updated_at?: string updated_at?: string
venue?: string user_id?: string
} }
Relationships: [ Relationships: [
{ {
foreignKeyName: "events_category_id_fkey" foreignKeyName: "credits_customer_id_fkey"
columns: ["category_id"] columns: ["customer_id"]
isOneToOne: false isOneToOne: false
referencedRelation: "event_categories" referencedRelation: "credit_customers"
referencedColumns: ["id"]
},
]
}
news: {
Row: {
content: string
created_at: string | null
created_by: string
id: string
image_url: string | null
title: string
updated_at: string | null
}
Insert: {
content: string
created_at?: string | null
created_by: string
id?: string
image_url?: string | null
title: string
updated_at?: string | null
}
Update: {
content?: string
created_at?: string | null
created_by?: string
id?: string
image_url?: string | null
title?: string
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "news_created_by_fkey"
columns: ["created_by"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
] ]
} }
profiles: { profiles: {
Row: { Row: {
avatar_url: string | null
created_at: string created_at: string
email: string currency: string
full_name: string | null full_name: string | null
id: string id: string
ministry_group: string | null shop_name: string | null
role: string
updated_at: string updated_at: string
} }
Insert: { Insert: {
avatar_url?: string | null
created_at?: string created_at?: string
email: string currency?: string
full_name?: string | null full_name?: string | null
id: string id: string
ministry_group?: string | null shop_name?: string | null
role?: string
updated_at?: string updated_at?: string
} }
Update: { Update: {
avatar_url?: string | null
created_at?: string created_at?: string
email?: string currency?: string
full_name?: string | null full_name?: string | null
id?: string id?: string
ministry_group?: string | null shop_name?: string | null
role?: string
updated_at?: string updated_at?: string
} }
Relationships: [] Relationships: []
} }
repayments: {
Row: {
amount: number
created_at: string
customer_id: string
id: string
note: string | null
repayment_date: string
updated_at: string
user_id: string
}
Insert: {
amount: number
created_at?: string
customer_id: string
id?: string
note?: string | null
repayment_date?: string
updated_at?: string
user_id: string
}
Update: {
amount?: number
created_at?: string
customer_id?: string
id?: string
note?: string | null
repayment_date?: string
updated_at?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "repayments_customer_id_fkey"
columns: ["customer_id"]
isOneToOne: false
referencedRelation: "credit_customers"
referencedColumns: ["id"]
},
]
}
sales: {
Row: {
amount: number
created_at: string
id: string
note: string | null
sale_date: string
updated_at: string
user_id: string
}
Insert: {
amount: number
created_at?: string
id?: string
note?: string | null
sale_date?: string
updated_at?: string
user_id: string
}
Update: {
amount?: number
created_at?: string
id?: string
note?: string | null
sale_date?: string
updated_at?: string
user_id?: string
}
Relationships: []
}
} }
Views: { Views: {
[_ in never]: never [_ in never]: never
-685
View File
@@ -1,685 +0,0 @@
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;
-330
View File
@@ -1,330 +0,0 @@
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;
-323
View File
@@ -1,323 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
const Auth: React.FC = () => {
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 [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(null), 4000);
};
const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
if (error.message.toLowerCase().includes('email not confirmed')) {
await supabase.auth.resend({ type: 'signup', email });
history.push('/verify-email', { email, resent: true });
setLoading(false);
return;
} else {
showError(error.message);
}
setLoading(false);
return;
}
// Success: keep loading=true — useEffect above navigates when user arrives in context
};
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
showError('Passwords do not match');
return;
}
setLoading(true);
setError(null);
const { error } = await supabase.auth.signUp({ email, password });
if (error) {
showError(error.message);
setLoading(false);
return;
}
history.push('/verify-email', { email });
setLoading(false);
};
const handleTabChange = (tab: 'login' | 'register') => {
setActiveTab(tab);
setError(null);
if (tab === 'login') {
setConfirmPassword('');
}
};
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',
}}
>
{/* 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>
{/* Auth mode pill switcher */}
<div
style={{
background: '#ffffff',
borderRadius: '999px',
padding: '4px',
margin: '0 0 20px 0',
display: 'flex',
gap: '4px',
}}
>
<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>
{/* 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"
placeholder="Email address"
value={email}
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',
}}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{
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',
justifyContent: 'center',
marginTop: '8px',
}}
>
{loading ? (
<IonSpinner name="dots" color="light" />
) : activeTab === 'login' ? (
'Sign In'
) : (
'Create Account'
)}
</button>
</form>
{/* Error area */}
{error && (
<div
style={{
background: 'rgba(190, 60, 60, 0.10)',
borderRadius: '12px',
padding: '12px 14px',
margin: '0',
display: 'flex',
}}
>
<p
style={{
margin: 0,
fontSize: '13px',
fontWeight: '500',
color: '#a33b32',
lineHeight: '1.4',
}}
>
{error}
</p>
</div>
)}
</div>
</IonContent>
</IonPage>
);
};
export default Auth;
+233
View File
@@ -0,0 +1,233 @@
import React, { useState } from 'react';
import {
IonContent,
IonPage,
IonInput,
IonButton,
IonSpinner,
IonIcon,
IonText,
} from '@ionic/react';
import { storefrontOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
const AuthPage: React.FC = () => {
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [messageType, setMessageType] = useState<'error' | 'success'>('error');
const history = useHistory();
const showMessage = (msg: string, type: 'error' | 'success' = 'error') => {
setMessage(msg);
setMessageType(type);
setTimeout(() => setMessage(null), 4000);
};
const handleAuth = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password || (mode === 'signup' && !name)) {
showMessage('Please fill in all fields');
return;
}
setSubmitting(true);
setMessage(null);
try {
if (mode === 'signup') {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: name,
},
},
});
if (error) throw error;
if (data.session) {
history.push('/home');
} else {
history.push('/verify-email', { email });
}
} else {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
if (error.message.toLowerCase().includes('email not confirmed')) {
await supabase.auth.resend({ type: 'signup', email });
history.push('/verify-email', { email, resent: true });
return;
}
throw error;
}
if (data.session) {
history.push('/home');
}
}
} catch (err: any) {
showMessage(err.message || 'An authentication error occurred');
} finally {
setSubmitting(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background':
'linear-gradient(160deg, rgba(199,210,254,0.52) 0%, #f0f4ff 58%, #f8fafc 100%)',
}}
>
<div className="auth-shell">
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginTop: '20px',
}}
>
<div
className="icon-container"
style={{ width: '44px', height: '44px' }}
>
<IonIcon icon={storefrontOutline} style={{ fontSize: '22px' }} />
</div>
<div>
<h1 className="bold" style={{ margin: '0', fontSize: '22px' }}>
ShopKeep
</h1>
<p className="secondary-text" style={{ margin: '0' }}>
Simple bookkeeping for you
</p>
</div>
</div>
<div className="auth-card">
<div className="pill-toggle">
<button
className={`pill-toggle-btn ${mode === 'signin' ? 'active' : ''}`}
onClick={() => setMode('signin')}
>
Sign in
</button>
<button
className={`pill-toggle-btn ${mode === 'signup' ? 'active' : ''}`}
onClick={() => setMode('signup')}
>
Create account
</button>
</div>
<form
onSubmit={handleAuth}
style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
>
{mode === 'signup' && (
<div className="input-wrapper">
<IonInput
label="Full Name"
labelPlacement="floating"
placeholder="Enter your name"
value={name}
onIonChange={(e) => setName(e.detail.value!)}
/>
</div>
)}
<div className="input-wrapper">
<IonInput
label="Email"
labelPlacement="floating"
type="email"
placeholder="name@example.com"
value={email}
onIonChange={(e) => setEmail(e.detail.value!)}
/>
</div>
<div className="input-wrapper">
<IonInput
label="Password"
labelPlacement="floating"
type="password"
placeholder="Min. 6 characters"
value={password}
onIonChange={(e) => setPassword(e.detail.value!)}
/>
</div>
{message && (
<div
style={{
background:
messageType === 'error'
? 'var(--color-danger-soft)'
: 'var(--color-success-soft)',
color:
messageType === 'error'
? 'var(--ion-color-danger-shade)'
: 'var(--ion-color-success-shade)',
padding: '12px',
borderRadius: '12px',
fontSize: '13px',
}}
>
{message}
</div>
)}
<IonButton
type="submit"
expand="block"
className="bold"
style={{
height: '52px',
'--border-radius': 'var(--radius-pill)',
marginTop: '8px',
}}
disabled={submitting}
>
{submitting ? (
<IonSpinner name="crescent" />
) : mode === 'signin' ? (
'Sign in'
) : (
'Create account'
)}
</IonButton>
</form>
<div style={{ textAlign: 'center', marginTop: '8px' }}>
<IonText
color="primary"
className="semibold"
style={{ fontSize: '13px', cursor: 'pointer' }}
onClick={() => history.push('/forgot-password')}
>
Forgot password?
</IonText>
</div>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default AuthPage;
+359
View File
@@ -0,0 +1,359 @@
import React, { useState, useEffect } from 'react';
import {
IonContent,
IonPage,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonInput,
IonTextarea,
IonSpinner,
IonLabel,
IonSelect,
IonSelectOption,
} from '@ionic/react';
import { chevronBackOutline } from 'ionicons/icons';
import { useHistory, useLocation } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { getTodayISO } from '../utils/dates';
import MoneyInput from '../components/MoneyInput';
import { parseMoney } from '../utils/money';
interface Customer {
id: string;
name: string;
}
const CreditFormPage: React.FC = () => {
const history = useHistory();
const location = useLocation<{ customerId?: string }>();
const { user } = useAuth();
const [mode, setMode] = useState<'existing' | 'new'>('existing');
const [customers, setCustomers] = useState<Customer[]>([]);
const [selectedCustomerId, setSelectedCustomerId] = useState<string>(
location.state?.customerId || ''
);
const [newName, setNewName] = useState('');
const [phone, setPhone] = useState('');
const [amount, setAmount] = useState('');
const [creditDate, setCreditDate] = useState(getTodayISO());
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const [loadingCustomers, setLoadingCustomers] = useState(true);
const [errors, setErrors] = useState<Record<string, string>>({});
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
const fetchCustomers = async () => {
if (!user) return;
try {
const { data, error } = await supabase
.from('credit_customers')
.select('id, name')
.eq('user_id', user.id)
.order('name');
if (error) throw error;
setCustomers(data || []);
if (!data || data.length === 0) {
setMode('new');
}
} catch (err) {
console.error('Error fetching customers:', err);
} finally {
setLoadingCustomers(false);
}
};
fetchCustomers();
}, [user]);
const validate = () => {
const newErrors: Record<string, string> = {};
const parsedAmount = parseMoney(amount);
if (mode === 'existing' && !selectedCustomerId) {
newErrors.customer = 'Please select a customer';
}
if (mode === 'new' && !newName.trim()) {
newErrors.name = 'Customer name is required';
}
if (parsedAmount <= 0) {
newErrors.amount = 'Amount must be greater than 0';
}
if (!creditDate) {
newErrors.date = 'Date is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = async () => {
if (!validate() || !user) return;
setSubmitting(true);
setMessage(null);
try {
let customerId = selectedCustomerId;
if (mode === 'new') {
const { data: newCustomer, error: customerError } = await supabase
.from('credit_customers')
.insert([
{
user_id: user.id,
name: newName.trim(),
phone: phone.trim() || null,
},
])
.select()
.single();
if (customerError) throw customerError;
customerId = newCustomer.id;
}
const { error: creditError } = await supabase.from('credits').insert([
{
user_id: user.id,
customer_id: customerId,
amount: parseMoney(amount),
credit_date: creditDate,
description: description.trim() || null,
},
]);
if (creditError) throw creditError;
history.goBack();
} catch (err: any) {
console.error('Error saving credit:', err);
setMessage(err.message || 'Could not save credit');
setTimeout(() => setMessage(null), 4000);
} finally {
setSubmitting(false);
}
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f0f4ff' }}>
<IonButtons slot="start">
<IonButton color="dark" onClick={() => history.goBack()}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle className="bold">Add credit</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding" style={{ '--background': '#f0f4ff' }}>
<div className="form-card">
<div className="pill-toggle" style={{ marginBottom: '8px' }}>
<button
className={`pill-toggle-btn ${mode === 'existing' ? 'active' : ''}`}
onClick={() => setMode('existing')}
disabled={customers.length === 0}
>
Existing Customer
</button>
<button
className={`pill-toggle-btn ${mode === 'new' ? 'active' : ''}`}
onClick={() => setMode('new')}
>
New Customer
</button>
</div>
{mode === 'existing' ? (
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Select Customer
</IonLabel>
<div className="input-wrapper">
<IonSelect
interface="action-sheet"
placeholder="Choose a customer"
value={selectedCustomerId}
onIonChange={(e) => setSelectedCustomerId(e.detail.value)}
style={{ width: '100%' }}
>
{customers.map((c) => (
<IonSelectOption key={c.id} value={c.id}>
{c.name}
</IonSelectOption>
))}
</IonSelect>
</div>
{errors.customer && (
<IonLabel
color="danger"
style={{ fontSize: '12px', marginTop: '4px' }}
>
{errors.customer}
</IonLabel>
)}
</div>
) : (
<>
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Customer Name
</IonLabel>
<div className="input-wrapper">
<IonInput
placeholder="e.g. John Doe"
value={newName}
onIonChange={(e) => setNewName(e.detail.value!)}
/>
</div>
{errors.name && (
<IonLabel
color="danger"
style={{ fontSize: '12px', marginTop: '4px' }}
>
{errors.name}
</IonLabel>
)}
</div>
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Phone (optional)
</IonLabel>
<div className="input-wrapper">
<IonInput
type="tel"
placeholder="e.g. +1 234 567 890"
value={phone}
onIonChange={(e) => setPhone(e.detail.value!)}
/>
</div>
</div>
</>
)}
<MoneyInput
label="Credit amount"
value={amount}
onValueChange={(val) => {
setAmount(val);
if (errors.amount) setErrors({ ...errors, amount: '' });
}}
error={errors.amount}
/>
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Credit date
</IonLabel>
<div className="input-wrapper">
<IonInput
type="date"
value={creditDate}
onIonChange={(e) => setCreditDate(e.detail.value!)}
/>
</div>
{errors.date && (
<IonLabel
color="danger"
style={{ fontSize: '12px', marginTop: '4px' }}
>
{errors.date}
</IonLabel>
)}
</div>
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Description (optional)
</IonLabel>
<div
className="input-wrapper"
style={{ minHeight: '96px', padding: '8px 16px' }}
>
<IonTextarea
placeholder="e.g. 2 bags of rice, sugar"
value={description}
onIonChange={(e) => setDescription(e.detail.value!)}
rows={4}
/>
</div>
</div>
{message && (
<div
style={{
background: 'var(--color-danger-soft)',
color: 'var(--ion-color-danger-shade)',
padding: '12px',
borderRadius: '12px',
fontSize: '13px',
}}
>
{message}
</div>
)}
<IonButton
expand="block"
className="bold"
style={{
height: '52px',
'--border-radius': 'var(--radius-pill)',
marginTop: '16px',
}}
disabled={submitting || (loadingCustomers && mode === 'existing')}
onClick={handleSave}
>
{submitting ? <IonSpinner name="crescent" /> : 'Save credit'}
</IonButton>
</div>
</IonContent>
</IonPage>
);
};
export default CreditFormPage;
+361
View File
@@ -0,0 +1,361 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
IonContent,
IonPage,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonRefresher,
IonRefresherContent,
IonAlert,
useIonViewWillEnter,
} from '@ionic/react';
import {
chevronBackOutline,
trashOutline,
receiptOutline,
personAddOutline,
cashOutline,
} from 'ionicons/icons';
import { useHistory, useParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../supabase';
import ActivityRow, { ActivityItem } from '../components/ActivityRow';
import EmptyState from '../components/EmptyState';
import { formatMoney } from '../utils/money';
const CustomerDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { user, profile } = useAuth();
const history = useHistory();
const currency = profile?.currency || 'USD';
const [loading, setLoading] = useState(true);
const [customer, setCustomer] = useState<any>(null);
const [activity, setActivity] = useState<ActivityItem[]>([]);
const [balance, setBalance] = useState(0);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const fetchData = useCallback(async () => {
if (!user || !id) return;
setLoading(true);
try {
const [
{ data: customerData },
{ data: creditsData },
{ data: repaymentsData },
] = await Promise.all([
supabase.from('credit_customers').select('*').eq('id', id).single(),
supabase
.from('credits')
.select('*')
.eq('customer_id', id)
.order('credit_date', { ascending: false }),
supabase
.from('repayments')
.select('*')
.eq('customer_id', id)
.order('repayment_date', { ascending: false }),
]);
if (customerData) {
setCustomer(customerData);
const totalCredits = (creditsData || []).reduce(
(sum, c) => sum + Number(c.amount),
0
);
const totalRepayments = (repaymentsData || []).reduce(
(sum, r) => sum + Number(r.amount),
0
);
setBalance(Math.max(0, totalCredits - totalRepayments));
const combinedActivity: ActivityItem[] = [
...(creditsData || []).map((c) => ({
id: c.id,
type: 'credit' as const,
amount: Number(c.amount),
date: c.credit_date,
note: c.description,
})),
...(repaymentsData || []).map((r) => ({
id: r.id,
type: 'repayment' as const,
amount: Number(r.amount),
date: r.repayment_date,
note: r.note,
})),
].sort((a, b) => b.date.localeCompare(a.date));
setActivity(combinedActivity);
}
} catch (err) {
console.error('Error fetching customer detail:', err);
} finally {
setLoading(false);
}
}, [user, id]);
useIonViewWillEnter(() => {
fetchData();
}, [fetchData]);
const handleRefresh = async (e: CustomEvent) => {
await fetchData();
e.detail.complete();
};
const handleDelete = async () => {
try {
const { error } = await supabase
.from('credit_customers')
.delete()
.eq('id', id);
if (error) throw error;
history.push('/customers');
} catch (err) {
console.error('Error deleting customer:', err);
}
};
const getInitials = (name: string) => {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
if (!loading && !customer) {
return (
<IonPage>
<IonContent
className="ion-padding"
style={{ '--background': '#f0f4ff' }}
>
<EmptyState
icon="alert-circle-outline"
title="Customer not found"
actionText="Go Back"
onAction={() => history.push('/customers')}
/>
</IonContent>
</IonPage>
);
}
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f0f4ff' }}>
<IonButtons slot="start">
<IonButton color="dark" onClick={() => history.push('/customers')}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle className="bold">Customer</IonTitle>
<IonButtons slot="end">
<IonButton color="danger" onClick={() => setShowDeleteAlert(true)}>
<IonIcon icon={trashOutline} slot="icon-only" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent fullscreen style={{ '--background': '#f0f4ff' }}>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div className="page-padding" style={{ paddingTop: '16px' }}>
<div className="widget-card" style={{ padding: '24px 20px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
marginBottom: '20px',
}}
>
<div
className="icon-container bold"
style={{
width: '56px',
height: '56px',
fontSize: '18px',
marginRight: '16px',
borderRadius: '16px',
}}
>
{customer ? getInitials(customer.name) : ''}
</div>
<div>
<h2 className="bold" style={{ margin: '0', fontSize: '20px' }}>
{customer?.name}
</h2>
{customer?.phone && (
<p className="secondary-text" style={{ margin: '2px 0 0 0' }}>
{customer.phone}
</p>
)}
</div>
</div>
<div style={{ marginBottom: '24px' }}>
<span className="tertiary-text">OUTSTANDING BALANCE</span>
<div
className="tabular"
style={{
fontSize: '34px',
fontWeight: '400',
color:
balance > 0
? 'var(--ion-color-danger)'
: 'var(--ion-color-success)',
marginTop: '4px',
}}
>
{formatMoney(balance, currency)}
</div>
<div
style={{
display: 'inline-block',
padding: '4px 12px',
borderRadius: '999px',
fontSize: '12px',
fontWeight: '600',
marginTop: '8px',
background:
balance > 0
? 'var(--color-danger-soft)'
: 'var(--color-success-soft)',
color:
balance > 0
? 'var(--ion-color-danger)'
: 'var(--ion-color-success)',
}}
>
{balance > 0 ? 'OWING' : 'CLEARED'}
</div>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<IonButton
fill="clear"
className="bold"
style={{
flex: 1,
fontSize: '14px',
background: 'var(--color-surface-secondary)',
color: 'var(--color-text-primary)',
'--border-radius': 'var(--radius-inner)',
height: '44px',
}}
onClick={() => history.push('/credit/new', { customerId: id })}
>
<IonIcon icon={personAddOutline} slot="start" />
Credit
</IonButton>
<IonButton
fill="solid"
className="bold"
style={{
flex: 1,
fontSize: '14px',
'--border-radius': 'var(--radius-inner)',
height: '44px',
}}
onClick={() =>
history.push('/repayment/new', { customerId: id })
}
disabled={balance === 0}
>
<IonIcon icon={cashOutline} slot="start" />
Repay
</IonButton>
</div>
</div>
</div>
<div className="section-header">
<h2>Activity</h2>
</div>
<div className="page-padding" style={{ paddingBottom: '40px' }}>
{loading ? (
<div className="widget-card" style={{ gap: '16px' }}>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '12px',
background: 'var(--color-surface-secondary)',
}}
/>
<div style={{ flex: 1 }}>
<div
style={{
width: '60%',
height: '12px',
background: 'var(--color-surface-secondary)',
borderRadius: '4px',
marginBottom: '6px',
}}
/>
<div
style={{
width: '40%',
height: '10px',
background: 'var(--color-surface-secondary)',
borderRadius: '4px',
}}
/>
</div>
</div>
))}
</div>
) : activity.length > 0 ? (
<div className="widget-card" style={{ padding: '8px 16px' }}>
{activity.map((item) => (
<ActivityRow
key={`${item.type}-${item.id}`}
item={item}
showDate
/>
))}
</div>
) : (
<EmptyState
icon={receiptOutline}
title="No activity yet"
description="Record the first credit or repayment for this customer."
/>
)}
</div>
<IonAlert
isOpen={showDeleteAlert}
onDidDismiss={() => setShowDeleteAlert(false)}
header="Delete Customer"
message="Are you sure you want to delete this customer? All their credit and repayment history will be lost."
buttons={[
{ text: 'Cancel', role: 'cancel' },
{ text: 'Delete', role: 'destructive', handler: handleDelete },
]}
/>
</IonContent>
</IonPage>
);
};
export default CustomerDetailPage;
+268
View File
@@ -0,0 +1,268 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
IonContent,
IonPage,
IonIcon,
IonSegmentButton,
IonSearchbar,
IonRefresher,
IonRefresherContent,
useIonViewWillEnter,
} from '@ionic/react';
import { personAddOutline, peopleOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../supabase';
import MetricCard from '../components/MetricCard';
import CustomerRow from '../components/CustomerRow';
import EmptyState from '../components/EmptyState';
interface CustomerWithBalance {
id: string;
name: string;
phone: string | null;
balance: number;
}
const CustomersPage: React.FC = () => {
const { user } = useAuth();
const history = useHistory();
const [loading, setLoading] = useState(true);
const [customers, setCustomers] = useState<CustomerWithBalance[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const fetchData = useCallback(async () => {
if (!user) return;
setLoading(true);
try {
const [
{ data: customersData },
{ data: creditsData },
{ data: repaymentsData },
] = await Promise.all([
supabase
.from('credit_customers')
.select('*')
.eq('user_id', user.id)
.order('name'),
supabase
.from('credits')
.select('customer_id, amount')
.eq('user_id', user.id),
supabase
.from('repayments')
.select('customer_id, amount')
.eq('user_id', user.id),
]);
const customerBalances: CustomerWithBalance[] = (customersData || []).map(
(c) => {
const totalCredits = (creditsData || [])
.filter((cr) => cr.customer_id === c.id)
.reduce((sum, cr) => sum + Number(cr.amount), 0);
const totalRepayments = (repaymentsData || [])
.filter((r) => r.customer_id === c.id)
.reduce((sum, r) => sum + Number(r.amount), 0);
return {
id: c.id,
name: c.name,
phone: c.phone,
balance: Math.max(0, totalCredits - totalRepayments),
};
},
);
// Sort: outstanding balances first, then by name
setCustomers(
customerBalances.sort((a, b) => {
if (a.balance > 0 && b.balance === 0) return -1;
if (a.balance === 0 && b.balance > 0) return 1;
return a.name.localeCompare(b.name);
}),
);
} catch (err) {
console.error('Error fetching customers:', err);
} finally {
setLoading(false);
}
}, [user]);
useIonViewWillEnter(() => {
fetchData();
}, [fetchData]);
const handleRefresh = async (e: CustomEvent) => {
await fetchData();
e.detail.complete();
};
const filteredCustomers = useMemo(() => {
const query = searchQuery.toLowerCase().trim();
if (!query) return customers;
return customers.filter(
(c) =>
c.name.toLowerCase().includes(query) ||
(c.phone && c.phone.toLowerCase().includes(query)),
);
}, [customers, searchQuery]);
const totalOutstanding = useMemo(() => {
return customers.reduce((sum, c) => sum + c.balance, 0);
}, [customers]);
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background':
'linear-gradient(160deg, rgba(199,210,254,0.35) 0%, #f0f4ff 58%, #f8fafc 100%)',
}}
>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div
style={{
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<h1 className="bold" style={{ margin: '0', fontSize: '20px' }}>
Credit customers
</h1>
<p className="secondary-text" style={{ margin: '0' }}>
Track who owes your shop
</p>
</div>
<div
className="icon-container"
style={{
borderRadius: '12px',
background: '#ffffff',
color: 'var(--ion-color-primary)',
}}
onClick={() => history.push('/credit/new')}
>
<IonIcon icon={personAddOutline} style={{ fontSize: '22px' }} />
</div>
</div>
<div className="page-padding">
<IonSearchbar
value={searchQuery}
onIonInput={(e) => setSearchQuery(e.detail.value!)}
placeholder="Search customers"
className="custom-searchbar"
style={{
'--background': 'var(--color-surface-secondary)',
'--border-radius': '24px',
'--box-shadow': 'none',
padding: '0',
height: '44px',
marginBottom: '16px',
}}
/>
<MetricCard
label="Total Outstanding"
value={totalOutstanding}
fullWidth
loading={loading}
/>
</div>
<div className="section-header">
<h2>
Customers{' '}
{!loading && (
<span className="tertiary-text" style={{ marginLeft: '4px' }}>
({customers.length})
</span>
)}
</h2>
</div>
<div className="page-padding" style={{ paddingBottom: '120px' }}>
{loading ? (
<div className="widget-card" style={{ gap: '16px' }}>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
>
<div
style={{
width: '44px',
height: '44px',
borderRadius: '12px',
background: 'var(--color-surface-secondary)',
}}
/>
<div style={{ flex: 1 }}>
<div
style={{
width: '50%',
height: '14px',
background: 'var(--color-surface-secondary)',
borderRadius: '4px',
marginBottom: '6px',
}}
/>
<div
style={{
width: '30%',
height: '10px',
background: 'var(--color-surface-secondary)',
borderRadius: '4px',
}}
/>
</div>
</div>
))}
</div>
) : customers.length > 0 ? (
<>
{filteredCustomers.length > 0 ? (
<div className="widget-card" style={{ padding: '8px 16px' }}>
{filteredCustomers.map((c) => (
<CustomerRow
key={c.id}
id={c.id}
name={c.name}
phone={c.phone || undefined}
balance={c.balance}
onClick={() => history.push(`/customers/${c.id}`)}
/>
))}
</div>
) : (
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
<p className="secondary-text">
No customers matching "{searchQuery}"
</p>
</div>
)}
</>
) : (
<EmptyState
icon={peopleOutline}
title="No credit customers yet"
description="Add a credit sale to start tracking balances."
actionText="Add Credit"
onAction={() => history.push('/credit/new')}
/>
)}
</div>
</IonContent>
</IonPage>
);
};
export default CustomersPage;
-380
View File
@@ -1,380 +0,0 @@
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;
-350
View File
@@ -1,350 +0,0 @@
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;
+162
View File
@@ -0,0 +1,162 @@
import React, { useState } from 'react';
import {
IonContent,
IonPage,
IonInput,
IonButton,
IonSpinner,
IonIcon,
} from '@ionic/react';
import { chevronBackOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
const ForgotPasswordPage: React.FC = () => {
const [email, setEmail] = useState('');
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [messageType, setMessageType] = useState<'error' | 'success'>('error');
const history = useHistory();
const showMessage = (msg: string, type: 'error' | 'success' = 'error') => {
setMessage(msg);
setMessageType(type);
setTimeout(() => setMessage(null), 4000);
};
const handleReset = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
showMessage('Please enter your email');
return;
}
setSubmitting(true);
setMessage(null);
try {
const { error } = await supabase.auth.resetPasswordForEmail(email);
if (error) throw error;
history.push('/verify-email', { email, type: 'recovery' });
} catch (err: any) {
showMessage(err.message || 'An error occurred');
} finally {
setSubmitting(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background':
'linear-gradient(160deg, rgba(199,210,254,0.52) 0%, #f0f4ff 58%, #f8fafc 100%)',
}}
>
<div className="auth-shell">
<IonButton
fill="clear"
color="dark"
onClick={() => history.push('/auth')}
style={{
position: 'absolute',
top: 'calc(var(--ion-safe-area-top, 0px) + 8px)',
left: '8px',
'--padding-start': '8px',
'--padding-end': '8px',
zIndex: 10,
}}
>
<div
style={{
background: '#ffffff',
width: '44px',
height: '44px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon icon={chevronBackOutline} />
</div>
</IonButton>
<div style={{ marginTop: '80px' }}>
<h1
className="bold"
style={{ fontSize: '24px', margin: '0 0 8px 0' }}
>
Reset password
</h1>
<p
className="secondary-text"
style={{ margin: '0', fontSize: '14px' }}
>
Enter your email and we'll send a 6-digit recovery code.
</p>
</div>
<div className="auth-card">
<form
onSubmit={handleReset}
style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
>
<div className="input-wrapper">
<IonInput
label="Email"
labelPlacement="floating"
type="email"
placeholder="name@example.com"
value={email}
onIonChange={(e) => setEmail(e.detail.value!)}
/>
</div>
{message && (
<div
style={{
background:
messageType === 'error'
? 'var(--color-danger-soft)'
: 'var(--color-success-soft)',
color:
messageType === 'error'
? 'var(--ion-color-danger-shade)'
: 'var(--ion-color-success-shade)',
padding: '12px',
borderRadius: '12px',
fontSize: '13px',
textAlign: 'center',
}}
>
{message}
</div>
)}
<IonButton
type="submit"
expand="block"
className="bold"
style={{
height: '52px',
'--border-radius': 'var(--radius-pill)',
marginTop: '8px',
}}
disabled={submitting}
>
{submitting ? <IonSpinner name="crescent" /> : 'Send code'}
</IonButton>
</form>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default ForgotPasswordPage;
+339
View File
@@ -0,0 +1,339 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
IonContent,
IonPage,
IonIcon,
IonRefresher,
IonRefresherContent,
IonSegment,
IonSegmentButton,
IonLabel,
IonActionSheet,
useIonViewWillEnter,
} from '@ionic/react';
import {
addOutline,
receiptOutline,
personAddOutline,
cashOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../supabase';
import ActivityRow, { ActivityItem } from '../components/ActivityRow';
import EmptyState from '../components/EmptyState';
import MetricCard from '../components/MetricCard';
import { groupItemsByDate, formatRelativeDate } from '../utils/dates';
const HistoryPage: React.FC = () => {
const { user } = useAuth();
const history = useHistory();
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<
'all' | 'sales' | 'credit' | 'repayments'
>('all');
const [items, setItems] = useState<ActivityItem[]>([]);
const [showQuickAdd, setShowQuickAdd] = useState(false);
const fetchData = useCallback(async () => {
if (!user) return;
setLoading(true);
try {
const [
{ data: salesData },
{ data: creditsData },
{ data: repaymentsData },
] = await Promise.all([
supabase
.from('sales')
.select('*')
.eq('user_id', user.id)
.order('sale_date', { ascending: false }),
supabase
.from('credits')
.select('*, credit_customers(name)')
.eq('user_id', user.id)
.order('credit_date', { ascending: false }),
supabase
.from('repayments')
.select('*, credit_customers(name)')
.eq('user_id', user.id)
.order('repayment_date', { ascending: false }),
]);
const combined: ActivityItem[] = [
...(salesData || []).map((s) => ({
id: s.id,
type: 'sale' as const,
amount: Number(s.amount),
date: s.sale_date,
note: s.note,
})),
...(creditsData || []).map((c) => ({
id: c.id,
type: 'credit' as const,
amount: Number(c.amount),
date: c.credit_date,
customerName: (c.credit_customers as any)?.name,
note: c.description,
customerId: c.customer_id,
})),
...(repaymentsData || []).map((r) => ({
id: r.id,
type: 'repayment' as const,
amount: Number(r.amount),
date: r.repayment_date,
customerName: (r.credit_customers as any)?.name,
note: r.note,
customerId: r.customer_id,
})),
].sort((a, b) => b.date.localeCompare(a.date));
setItems(combined);
} catch (err) {
console.error('Error fetching history:', err);
} finally {
setLoading(false);
}
}, [user]);
useIonViewWillEnter(() => {
fetchData();
}, [fetchData]);
const handleRefresh = async (e: CustomEvent) => {
await fetchData();
e.detail.complete();
};
const filteredItems = useMemo(() => {
if (filter === 'all') return items;
if (filter === 'sales') return items.filter((i) => i.type === 'sale');
if (filter === 'credit') return items.filter((i) => i.type === 'credit');
if (filter === 'repayments')
return items.filter((i) => i.type === 'repayment');
return items;
}, [items, filter]);
const groupedItems = useMemo(() => {
return groupItemsByDate(filteredItems);
}, [filteredItems]);
const totals = useMemo(() => {
const salesTotal = items
.filter((i) => i.type === 'sale')
.reduce((sum, i) => sum + i.amount, 0);
const repaymentsTotal = items
.filter((i) => i.type === 'repayment')
.reduce((sum, i) => sum + i.amount, 0);
return { salesTotal, repaymentsTotal };
}, [items]);
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background':
'linear-gradient(160deg, rgba(199,210,254,0.35) 0%, #f0f4ff 58%, #f8fafc 100%)',
}}
>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div
style={{
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<h1 className="bold" style={{ margin: '0', fontSize: '20px' }}>
History
</h1>
<p className="secondary-text" style={{ margin: '0' }}>
All your transactions
</p>
</div>
<div
className="icon-container"
style={{
borderRadius: '12px',
background: '#ffffff',
color: 'var(--ion-color-primary)',
}}
onClick={() => setShowQuickAdd(true)}
>
<IonIcon icon={addOutline} style={{ fontSize: '22px' }} />
</div>
</div>
<div className="page-padding">
<IonSegment
value={filter}
onIonChange={(e) => setFilter(e.detail.value as any)}
style={{
'--background': 'var(--color-surface-secondary)',
borderRadius: '12px',
padding: '4px',
marginBottom: '16px',
}}
>
<IonSegmentButton
value="all"
style={{
'--indicator-color': 'var(--ion-color-primary)',
color: 'var(--color-text-primary)',
}}
>
<IonLabel className="semibold">All</IonLabel>
</IonSegmentButton>
<IonSegmentButton value="sales">
<IonLabel className="semibold">Sales</IonLabel>
</IonSegmentButton>
<IonSegmentButton value="credit">
<IonLabel className="semibold">Credit</IonLabel>
</IonSegmentButton>
<IonSegmentButton value="repayments">
<IonLabel className="semibold">Paid</IonLabel>
</IonSegmentButton>
</IonSegment>
<div className="metric-pair-row">
<MetricCard
label="Total Sales"
value={totals.salesTotal}
loading={loading}
/>
<MetricCard
label="Total Repaid"
value={totals.repaymentsTotal}
loading={loading}
/>
</div>
</div>
<div
className="page-padding"
style={{ paddingBottom: '120px', marginTop: '8px' }}
>
{loading ? (
<div className="widget-card" style={{ gap: '16px' }}>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '12px',
background: 'var(--color-surface-secondary)',
}}
/>
<div style={{ flex: 1 }}>
<div
style={{
width: '60%',
height: '12px',
background: 'var(--color-surface-secondary)',
borderRadius: '4px',
marginBottom: '6px',
}}
/>
<div
style={{
width: '40%',
height: '10px',
background: 'var(--color-surface-secondary)',
borderRadius: '4px',
}}
/>
</div>
</div>
))}
</div>
) : groupedItems.length > 0 ? (
groupedItems.map((group) => (
<div key={group.date} style={{ marginBottom: '20px' }}>
<div className="tertiary-text" style={{ marginBottom: '12px' }}>
{formatRelativeDate(group.date)}
</div>
<div className="widget-card" style={{ padding: '8px 16px' }}>
{group.items.map((item) => (
<ActivityRow
key={`${item.type}-${item.id}`}
item={item}
onClick={
item.customerId
? () => history.push(`/customers/${item.customerId}`)
: undefined
}
/>
))}
</div>
</div>
))
) : (
<EmptyState
icon={receiptOutline}
title={
filter === 'all' ? 'No activity yet' : `No ${filter} found`
}
description={
filter === 'all'
? 'Start recording sales and credits to see your history.'
: `You haven't recorded any ${filter} yet.`
}
actionText={
filter === 'all' || filter === 'sales'
? 'Add Sale'
: filter === 'credit'
? 'Add Credit'
: 'Record Repayment'
}
onAction={() => {
if (filter === 'sales' || filter === 'all')
history.push('/sale/new');
else if (filter === 'credit') history.push('/credit/new');
else history.push('/repayment/new');
}}
/>
)}
</div>
<IonActionSheet
isOpen={showQuickAdd}
onDidDismiss={() => setShowQuickAdd(false)}
header="Quick Actions"
buttons={[
{
text: 'Add Sale',
icon: receiptOutline,
handler: () => history.push('/sale/new'),
},
{
text: 'Add Credit',
icon: personAddOutline,
handler: () => history.push('/credit/new'),
},
{
text: 'Record Repayment',
icon: cashOutline,
handler: () => history.push('/repayment/new'),
},
{ text: 'Cancel', role: 'cancel' },
]}
/>
</IonContent>
</IonPage>
);
};
export default HistoryPage;
+296 -527
View File
@@ -1,588 +1,357 @@
import React, { useState } from 'react'; import React, { useState, useCallback } from 'react';
import { import {
IonPage,
IonContent, IonContent,
IonSpinner, IonPage,
useIonViewWillEnter,
IonIcon, IonIcon,
IonButton,
IonActionSheet,
IonRefresher,
IonRefresherContent,
useIonViewWillEnter,
} from '@ionic/react'; } from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { ChurchEvent } from '../types';
import EventPreviewCard from '../components/EventPreviewCard';
import HomeStatCard from '../components/HomeStatCard';
import { import {
calendarOutline, addCircleOutline,
peopleOutline, personAddOutline,
newspaperOutline, cashOutline,
receiptOutline,
walletOutline,
checkmarkCircleOutline,
personCircleOutline,
} from 'ionicons/icons'; } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../supabase';
import MetricCard from '../components/MetricCard';
import ActivityRow, { ActivityItem } from '../components/ActivityRow';
import EmptyState from '../components/EmptyState';
import { getTodayISO } from '../utils/dates';
const Home: React.FC = () => { const Home: React.FC = () => {
const [featuredEvent, setFeaturedEvent] = useState<ChurchEvent | null>(null); const { user, profile } = useAuth();
const [upcomingEvents, setUpcomingEvents] = useState<ChurchEvent[]>([]);
const [latestNews, setLatestNews] = useState<any[]>([]);
const [weeklyCount, setWeeklyCount] = useState(0);
const [youthCount, setYouthCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { profile } = useAuth();
const history = useHistory(); const history = useHistory();
const loadHomeData = async () => { const [loading, setLoading] = useState(true);
setLoading(true); const [error, setError] = useState<string | null>(null);
setError(null); const [showQuickActions, setShowQuickActions] = useState(false);
const now = new Date().toISOString(); const [summary, setSummary] = useState({
todaySales: 0,
const [{ data, error: fetchError }, { data: newsData, error: newsError }] = outstandingCredit: 0,
await Promise.all([ monthRepayments: 0,
supabase recentActivity: [] as ActivityItem[],
.from('events')
.select('*, event_categories(*)')
.gte('start_at', now)
.order('start_at', { ascending: true })
.limit(20),
supabase
.from('news')
.select('*')
.order('created_at', { ascending: false })
.limit(3),
]);
if (newsData) {
setLatestNews(newsData);
}
if (fetchError) {
setError(fetchError.message);
setFeaturedEvent(null);
setUpcomingEvents([]);
setWeeklyCount(0);
setYouthCount(0);
setLoading(false);
return;
}
const allUpcoming = data as ChurchEvent[];
if (allUpcoming.length > 0) {
const featured = allUpcoming.find((e) => e.is_featured) || allUpcoming[0];
setFeaturedEvent(featured);
setUpcomingEvents(allUpcoming.slice(0, 4));
} else {
setFeaturedEvent(null);
setUpcomingEvents([]);
}
const weekly = allUpcoming.filter(
(e) => e.event_categories?.slug === 'weekly'
).length;
const youth = allUpcoming.filter(
(e) => e.audience === 'youth' || e.event_categories?.slug === 'youth'
).length;
setWeeklyCount(weekly);
setYouthCount(youth);
setLoading(false);
};
useIonViewWillEnter(() => {
loadHomeData();
}); });
const openEventDetail = (id: string) => { const fetchData = useCallback(async () => {
history.push(`/event/${id}`); if (!user) return;
setLoading(true);
setError(null);
try {
const today = getTodayISO();
const firstOfMonth = new Date();
firstOfMonth.setDate(1);
const firstOfMonthISO = firstOfMonth.toISOString().split('T')[0];
// Parallel queries for efficiency
const [
{ data: salesToday },
{ data: creditsData },
{ data: repaymentsData },
{ data: recentSales },
{ data: recentCredits },
{ data: recentRepayments },
] = await Promise.all([
supabase.from('sales').select('amount').eq('sale_date', today),
supabase.from('credits').select('amount'),
supabase.from('repayments').select('amount, repayment_date'),
supabase
.from('sales')
.select('*')
.order('created_at', { ascending: false })
.limit(5),
supabase
.from('credits')
.select('*, credit_customers(name)')
.order('created_at', { ascending: false })
.limit(5),
supabase
.from('repayments')
.select('*, credit_customers(name)')
.order('created_at', { ascending: false })
.limit(5),
]);
// Calculate totals
const todaySalesTotal = (salesToday || []).reduce(
(sum, s) => sum + Number(s.amount),
0
);
const totalCredits = (creditsData || []).reduce(
(sum, c) => sum + Number(c.amount),
0
);
const totalRepayments = (repaymentsData || []).reduce(
(sum, r) => sum + Number(r.amount),
0
);
const monthRepaymentsTotal = (repaymentsData || [])
.filter((r) => r.repayment_date >= firstOfMonthISO)
.reduce((sum, r) => sum + Number(r.amount), 0);
// Combine activity
const activity: ActivityItem[] = [
...(recentSales || []).map((s) => ({
id: s.id,
type: 'sale' as const,
amount: Number(s.amount),
date: s.sale_date,
note: s.note,
})),
...(recentCredits || []).map((c) => ({
id: c.id,
type: 'credit' as const,
amount: Number(c.amount),
date: c.credit_date,
customerName: (c.credit_customers as any)?.name,
note: c.description,
customerId: c.customer_id,
})),
...(recentRepayments || []).map((r) => ({
id: r.id,
type: 'repayment' as const,
amount: Number(r.amount),
date: r.repayment_date,
customerName: (r.credit_customers as any)?.name,
note: r.note,
customerId: r.customer_id,
})),
].sort((a, b) => b.id.localeCompare(a.id)); // Simplified sort for MVP, ideally use created_at
setSummary({
todaySales: todaySalesTotal,
outstandingCredit: Math.max(0, totalCredits - totalRepayments),
monthRepayments: monthRepaymentsTotal,
recentActivity: activity.slice(0, 10),
});
} catch (err: any) {
console.error('Error fetching dashboard data:', err);
setError('Could not load dashboard data');
} finally {
setLoading(false);
}
}, [user]);
useIonViewWillEnter(() => {
fetchData();
}, [fetchData]);
const handleRefresh = async (e: CustomEvent) => {
await fetchData();
e.detail.complete();
}; };
const goToEvents = () => { const getGreeting = () => {
history.push('/events'); const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 17) return 'Good afternoon';
return 'Good evening';
}; };
const initials = profile?.full_name
? profile.full_name
.split(' ')
.map((n) => n[0])
.join('')
.substring(0, 2)
.toUpperCase()
: 'U';
return ( return (
<IonPage style={{ background: 'var(--color-bg)' }}> <IonPage>
<IonContent <IonContent
fullscreen
style={{ style={{
'--background': 'linear-gradient(180deg, #f8f4ef 0%, #f3ece3 100%)', '--background':
'--padding-start': '0px', 'linear-gradient(160deg, rgba(199,210,254,0.45) 0%, #f0f4ff 58%, #f8fafc 100%)',
'--padding-end': '0px',
'--padding-top': '0px',
'--padding-bottom': '24px',
}} }}
> >
{/* Top Content Row */} <IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div <div
style={{ style={{
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 12px', padding: 'calc(16px + var(--ion-safe-area-top)) 20px 16px',
margin: '0',
display: 'flex', display: 'flex',
alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: '12px', alignItems: 'center',
}} }}
> >
<div> <div>
<div <div className="secondary-text">{getGreeting()}</div>
style={{ <h1 className="bold" style={{ margin: '0', fontSize: '20px' }}>
fontSize: '12px', {profile?.shop_name || profile?.full_name || 'My Shop'}
fontWeight: '600', </h1>
color: 'var(--color-text-secondary)',
textTransform: 'uppercase',
}}
>
Welcome to
</div> </div>
<div <div
className="icon-container"
style={{ style={{
fontSize: '18px', borderRadius: '999px',
fontWeight: '700', background: 'var(--color-accent-soft)',
}}
onClick={() => setShowQuickActions(true)}
>
<IonIcon icon={personCircleOutline} style={{ fontSize: '24px' }} />
</div>
</div>
<div className="page-padding">
<MetricCard
label="Today's Sales"
value={summary.todaySales}
fullWidth
loading={loading}
/>
<div className="metric-pair-row" style={{ marginTop: '12px' }}>
<MetricCard
label="Outstanding"
value={summary.outstandingCredit}
icon={walletOutline}
loading={loading}
/>
<MetricCard
label="Repaid (Month)"
value={summary.monthRepayments}
icon={checkmarkCircleOutline}
loading={loading}
/>
</div>
<div
className="pill-toggle"
style={{ marginTop: '12px', background: '#ffffff', padding: '8px' }}
>
<IonButton
fill="clear"
className="bold"
style={{
flex: 1,
fontSize: '13px',
color: 'var(--color-text-primary)', color: 'var(--color-text-primary)',
}} }}
onClick={() => history.push('/sale/new')}
> >
EAGLES ASSEMBLY <IonIcon icon={addCircleOutline} slot="start" />
Add Sale
</IonButton>
<IonButton
fill="clear"
className="bold"
style={{
flex: 1,
fontSize: '13px',
color: 'var(--color-text-primary)',
}}
onClick={() => history.push('/credit/new')}
>
<IonIcon icon={personAddOutline} slot="start" />
Credit
</IonButton>
<IonButton
fill="clear"
className="bold"
style={{
flex: 1,
fontSize: '13px',
color: 'var(--color-text-primary)',
}}
onClick={() => history.push('/repayment/new')}
>
<IonIcon icon={cashOutline} slot="start" />
Repay
</IonButton>
</div> </div>
</div> </div>
<div className="section-header">
<h2>Recent activity</h2>
<span className="see-all" onClick={() => history.push('/history')}>
See all
</span>
</div>
<div className="page-padding" style={{ paddingBottom: '120px' }}>
{loading ? (
<div className="widget-card" style={{ gap: '16px' }}>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
>
<div <div
onClick={() => history.push('/profile')}
style={{ style={{
width: '40px', width: '40px',
height: '40px', height: '40px',
borderRadius: '12px', borderRadius: '12px',
background: 'var(--color-brand-soft)', background: 'var(--color-surface-secondary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontWeight: '700',
color: 'var(--color-brand)',
cursor: 'pointer',
}}
>
{initials}
</div>
</div>
{/* Greeting Widget */}
<div
style={{
background: 'var(--color-surface)',
borderRadius: '24px',
padding: '22px',
margin: '0 20px 12px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'baseline',
gap: '6px',
flexWrap: 'wrap',
}}
>
<span
style={{
fontSize: '22px',
fontWeight: '400',
color: 'var(--color-text-primary)',
}}
>
Hello,
</span>
<span
style={{
fontSize: '22px',
fontWeight: '700',
color: 'var(--color-text-primary)',
}}
>
{profile?.full_name?.split(' ')[0] || 'Friend'}
</span>
</div>
<div
style={{
fontSize: '14px',
fontWeight: '400',
color: 'var(--color-text-secondary)',
}}
>
Here is what's happening at church this week.
</div>
</div>
{loading ? (
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '40px 0',
}}
>
<IonSpinner name="crescent" color="primary" />
</div>
) : (
<>
{/* Feature Upcoming Event Widget */}
{featuredEvent && (
<div
onClick={() => openEventDetail(featuredEvent.id)}
style={{
background: 'var(--color-brand)',
borderRadius: '24px',
padding: '22px',
margin: '0 20px 12px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
cursor: 'pointer',
}}
>
<div
style={{
fontSize: '11px',
fontWeight: '600',
color: 'rgba(255,255,255,0.70)',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}
>
Next Up
</div>
<div
style={{
fontSize: '24px',
fontWeight: '700',
color: '#ffffff',
lineHeight: '1.2',
}}
>
{featuredEvent.title}
</div>
<div
style={{
fontSize: '13px',
fontWeight: '500',
color: 'rgba(255,255,255,0.85)',
}}
>
{new Date(featuredEvent.start_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}{' '}
• {featuredEvent.day_label}
</div>
<div style={{ marginTop: '4px' }}>
<button
style={{
background: 'rgba(255,255,255,0.16)',
border: 'none',
borderRadius: '999px',
padding: '10px 16px',
fontSize: '13px',
fontWeight: '600',
color: '#ffffff',
}}
>
View Details
</button>
</div>
</div>
)}
{/* Latest News Row (Horizontal Scroll) */}
{latestNews.length > 0 && (
<div style={{ marginBottom: '24px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0 20px 12px',
}}
>
<div
style={{
fontSize: '16px',
fontWeight: '700',
color: 'var(--color-text-primary)',
}}
>
Latest News
</div>
<div
onClick={() => history.push('/news')}
style={{
fontSize: '14px',
fontWeight: '600',
color: 'var(--color-brand)',
cursor: 'pointer',
}}
>
See all
</div>
</div>
<div
style={{
display: 'flex',
overflowX: 'auto',
scrollSnapType: 'x mandatory',
padding: '0 20px',
gap: '12px',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
className="hide-scrollbar"
>
{latestNews.map((news) => (
<div
key={news.id}
onClick={() => history.push(`/news/${news.id}`)}
style={{
minWidth: '260px',
maxWidth: '260px',
background: 'var(--color-surface)',
borderRadius: '24px',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
scrollSnapAlign: 'start',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
cursor: 'pointer',
}}
>
{news.image_url ? (
<img
src={news.image_url}
alt=""
style={{
width: '100%',
height: '120px',
objectFit: 'cover',
borderRadius: '16px',
background: 'var(--color-surface-raised)',
}} }}
/> />
) : ( <div style={{ flex: 1 }}>
<div <div
style={{ style={{
width: '100%', width: '60%',
height: '120px', height: '12px',
borderRadius: '16px', background: 'var(--color-surface-secondary)',
background: 'var(--color-brand-soft)', borderRadius: '4px',
display: 'flex', marginBottom: '6px',
alignItems: 'center',
justifyContent: 'center',
}} }}
> />
<IonIcon <div
icon={newspaperOutline}
style={{ style={{
color: 'var(--color-brand)', width: '40%',
fontSize: '32px', height: '10px',
background: 'var(--color-surface-secondary)',
borderRadius: '4px',
}} }}
/> />
</div> </div>
)} </div>
<div> ))}
<div </div>
style={{ ) : summary.recentActivity.length > 0 ? (
fontSize: '12px', <div className="widget-card" style={{ padding: '8px 16px' }}>
fontWeight: '600', {summary.recentActivity.map((item) => (
color: 'var(--color-text-tertiary)', <ActivityRow
marginBottom: '4px', key={`${item.type}-${item.id}`}
}} item={item}
> onClick={
{new Date(news.created_at).toLocaleDateString( item.customerId
undefined, ? () => history.push(`/customers/${item.customerId}`)
{ : undefined
month: 'short',
day: 'numeric',
} }
)}
</div>
<div
style={{
fontSize: '15px',
fontWeight: '700',
color: 'var(--color-text-primary)',
lineHeight: '1.3',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{news.title}
</div>
</div>
</div>
))}
<div style={{ minWidth: '8px' }}></div>
</div>
<style>{`
.hide-scrollbar::-webkit-scrollbar { display: none; }
`}</style>
</div>
)}
{/* Two Stat Widgets */}
<div
style={{ display: 'flex', gap: '12px', margin: '0 20px 12px' }}
>
<HomeStatCard
label="Weekly"
value={weeklyCount.toString()}
caption="Upcoming events"
icon={calendarOutline}
/>
<HomeStatCard
label="Youth"
value={youthCount.toString()}
caption="Activities planned"
icon={peopleOutline}
/>
</div>
{/* Upcoming This Week List */}
{upcomingEvents.length > 0 ? (
<div
style={{
background: '#fcf8f0',
borderRadius: '24px',
padding: '20px',
margin: '0 20px 16px',
display: 'flex',
flexDirection: 'column',
gap: '14px',
boxShadow: '0 2px 8px rgba(123, 45, 38, 0.05)',
border: '1px solid rgba(176, 122, 58, 0.1)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div
style={{
fontSize: '16px',
fontWeight: '700',
color: 'var(--color-text-primary)',
}}
>
Upcoming Schedule
</div>
<div
onClick={goToEvents}
style={{
fontSize: '14px',
fontWeight: '600',
color: 'var(--color-brand)',
cursor: 'pointer',
}}
>
See all
</div>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
}}
>
{upcomingEvents.map((event) => (
<EventPreviewCard
key={event.id}
event={event}
onClick={() => openEventDetail(event.id)}
/> />
))} ))}
</div> </div>
</div>
) : ( ) : (
<div <EmptyState
style={{ icon={receiptOutline}
background: 'var(--color-surface)', title="No activity yet"
borderRadius: '24px', description="Record your first sale or credit to see it here."
padding: '32px 20px', actionText="Add Sale"
margin: '0 20px 16px', onAction={() => history.push('/sale/new')}
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '12px',
textAlign: 'center',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<IonIcon
icon={calendarOutline}
style={{
fontSize: '40px',
color: 'var(--color-text-tertiary)',
}}
/> />
<p )}
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: 'var(--color-text-secondary)',
}}
>
No church events have been posted yet.
</p>
<button
onClick={goToEvents}
style={{
background: 'var(--color-brand-soft)',
border: 'none',
borderRadius: '999px',
padding: '10px 16px',
fontSize: '14px',
fontWeight: '600',
color: 'var(--color-brand)',
cursor: 'pointer',
marginTop: '4px',
}}
>
Go to events
</button>
</div> </div>
)}
</>
)}
{error && ( <IonActionSheet
<div isOpen={showQuickActions}
style={{ onDidDismiss={() => setShowQuickActions(false)}
margin: '0 20px', header="Settings"
padding: '12px', buttons={[
background: 'rgba(190,60,60,0.1)', {
borderRadius: '12px', text: 'Sign Out',
color: '#a33b32', role: 'destructive',
fontSize: '13px', handler: () => {
}} supabase.auth.signOut();
> },
{error} },
</div> {
)} text: 'Cancel',
role: 'cancel',
},
]}
/>
</IonContent> </IonContent>
</IonPage> </IonPage>
); );
+180
View File
@@ -0,0 +1,180 @@
import React, { useState } from 'react';
import {
IonContent,
IonPage,
IonInput,
IonButton,
IonSpinner,
IonIcon,
} from '@ionic/react';
import { chevronBackOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
const NewPasswordPage: React.FC = () => {
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const history = useHistory();
const showMessage = (msg: string) => {
setMessage(msg);
setTimeout(() => setMessage(null), 4000);
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!password || !confirmPassword) {
showMessage('Please fill in all fields');
return;
}
if (password.length < 6) {
showMessage('Password must be at least 6 characters');
return;
}
if (password !== confirmPassword) {
showMessage('Passwords do not match');
return;
}
setSubmitting(true);
setMessage(null);
try {
const { error } = await supabase.auth.updateUser({ password });
if (error) throw error;
history.push('/home');
} catch (err: any) {
showMessage(err.message || 'An error occurred');
} finally {
setSubmitting(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background':
'linear-gradient(160deg, rgba(199,210,254,0.52) 0%, #f0f4ff 58%, #f8fafc 100%)',
}}
>
<div className="auth-shell">
<IonButton
fill="clear"
color="dark"
onClick={() => history.push('/auth')}
style={{
position: 'absolute',
top: 'calc(var(--ion-safe-area-top, 0px) + 8px)',
left: '8px',
'--padding-start': '8px',
'--padding-end': '8px',
zIndex: 10,
}}
>
<div
style={{
background: '#ffffff',
width: '44px',
height: '44px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon icon={chevronBackOutline} />
</div>
</IonButton>
<div style={{ marginTop: '80px' }}>
<h1
className="bold"
style={{ fontSize: '24px', margin: '0 0 8px 0' }}
>
Create new password
</h1>
<p
className="secondary-text"
style={{ margin: '0', fontSize: '14px' }}
>
Set a strong password to protect your account.
</p>
</div>
<div className="auth-card">
<form
onSubmit={handleUpdate}
style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
>
<div className="input-wrapper">
<IonInput
label="New Password"
labelPlacement="floating"
type="password"
placeholder="Min. 6 characters"
value={password}
onIonChange={(e) => setPassword(e.detail.value!)}
/>
</div>
<div className="input-wrapper">
<IonInput
label="Confirm Password"
labelPlacement="floating"
type="password"
placeholder="Repeat your password"
value={confirmPassword}
onIonChange={(e) => setConfirmPassword(e.detail.value!)}
/>
</div>
{message && (
<div
style={{
background: 'var(--color-danger-soft)',
color: 'var(--ion-color-danger-shade)',
padding: '12px',
borderRadius: '12px',
fontSize: '13px',
textAlign: 'center',
}}
>
{message}
</div>
)}
<IonButton
type="submit"
expand="block"
className="bold"
style={{
height: '52px',
'--border-radius': 'var(--radius-pill)',
marginTop: '8px',
}}
disabled={submitting}
>
{submitting ? (
<IonSpinner name="crescent" />
) : (
'Update password'
)}
</IonButton>
</form>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default NewPasswordPage;
-229
View File
@@ -1,229 +0,0 @@
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;
-187
View File
@@ -1,187 +0,0 @@
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;
-521
View File
@@ -1,521 +0,0 @@
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;
+353
View File
@@ -0,0 +1,353 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
IonContent,
IonPage,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonInput,
IonTextarea,
IonSpinner,
IonLabel,
IonSelect,
IonSelectOption,
} from '@ionic/react';
import { chevronBackOutline } from 'ionicons/icons';
import { useHistory, useLocation } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { getTodayISO } from '../utils/dates';
import MoneyInput from '../components/MoneyInput';
import { parseMoney, formatMoney } from '../utils/money';
import EmptyState from '../components/EmptyState';
interface CustomerWithBalance {
id: string;
name: string;
balance: number;
}
const RepaymentFormPage: React.FC = () => {
const history = useHistory();
const location = useLocation<{ customerId?: string }>();
const { user, profile } = useAuth();
const currency = profile?.currency || 'USD';
const [customers, setCustomers] = useState<CustomerWithBalance[]>([]);
const [selectedCustomerId, setSelectedCustomerId] = useState<string>(
location.state?.customerId || ''
);
const [amount, setAmount] = useState('');
const [repaymentDate, setRepaymentDate] = useState(getTodayISO());
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
const [loadingCustomers, setLoadingCustomers] = useState(true);
const [errors, setErrors] = useState<Record<string, string>>({});
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
const fetchCustomersAndBalances = async () => {
if (!user) return;
try {
const [
{ data: customersData },
{ data: creditsData },
{ data: repaymentsData },
] = await Promise.all([
supabase
.from('credit_customers')
.select('id, name')
.eq('user_id', user.id),
supabase
.from('credits')
.select('customer_id, amount')
.eq('user_id', user.id),
supabase
.from('repayments')
.select('customer_id, amount')
.eq('user_id', user.id),
]);
const customerBalances: CustomerWithBalance[] = (
customersData || []
).map((c) => {
const totalCredits = (creditsData || [])
.filter((cr) => cr.customer_id === c.id)
.reduce((sum, cr) => sum + Number(cr.amount), 0);
const totalRepayments = (repaymentsData || [])
.filter((r) => r.customer_id === c.id)
.reduce((sum, r) => sum + Number(r.amount), 0);
return {
id: c.id,
name: c.name,
balance: Math.max(0, totalCredits - totalRepayments),
};
});
// Filter for customers with an outstanding balance
const activeCustomers = customerBalances.filter((c) => c.balance > 0);
setCustomers(activeCustomers);
// If a customer was passed in but has no balance, we should handle it
if (
selectedCustomerId &&
!activeCustomers.find((c) => c.id === selectedCustomerId)
) {
setSelectedCustomerId('');
}
} catch (err) {
console.error('Error fetching customers for repayment:', err);
} finally {
setLoadingCustomers(false);
}
};
fetchCustomersAndBalances();
}, [user, selectedCustomerId]);
const selectedCustomer = useMemo(
() => customers.find((c) => c.id === selectedCustomerId),
[customers, selectedCustomerId]
);
const validate = () => {
const newErrors: Record<string, string> = {};
const parsedAmount = parseMoney(amount);
if (!selectedCustomerId) {
newErrors.customer = 'Please select a customer';
} else if (selectedCustomer && parsedAmount > selectedCustomer.balance) {
newErrors.amount = `Amount cannot exceed outstanding balance of ${formatMoney(
selectedCustomer.balance,
currency
)}`;
}
if (parsedAmount <= 0) {
newErrors.amount = 'Amount must be greater than 0';
}
if (!repaymentDate) {
newErrors.date = 'Date is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = async () => {
if (!validate() || !user) return;
setSubmitting(true);
setMessage(null);
try {
const { error } = await supabase.from('repayments').insert([
{
user_id: user.id,
customer_id: selectedCustomerId,
amount: parseMoney(amount),
repayment_date: repaymentDate,
note: note.trim() || null,
},
]);
if (error) throw error;
history.goBack();
} catch (err: any) {
console.error('Error saving repayment:', err);
setMessage(err.message || 'Could not save repayment');
setTimeout(() => setMessage(null), 4000);
} finally {
setSubmitting(false);
}
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f0f4ff' }}>
<IonButtons slot="start">
<IonButton color="dark" onClick={() => history.goBack()}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle className="bold">Record repayment</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding" style={{ '--background': '#f0f4ff' }}>
{!loadingCustomers && customers.length === 0 ? (
<EmptyState
icon="wallet-outline"
title="No outstanding credit"
description="You don't have any customers with an outstanding balance right now."
actionText="Add Credit"
onAction={() => history.push('/credit/new')}
/>
) : (
<div className="form-card">
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Select Customer
</IonLabel>
<div className="input-wrapper">
<IonSelect
interface="action-sheet"
placeholder="Choose a customer"
value={selectedCustomerId}
onIonChange={(e) => setSelectedCustomerId(e.detail.value)}
style={{ width: '100%' }}
disabled={loadingCustomers}
>
{customers.map((c) => (
<IonSelectOption key={c.id} value={c.id}>
{c.name} ({formatMoney(c.balance, currency)})
</IonSelectOption>
))}
</IonSelect>
</div>
{errors.customer && (
<IonLabel
color="danger"
style={{ fontSize: '12px', marginTop: '4px' }}
>
{errors.customer}
</IonLabel>
)}
</div>
{selectedCustomer && (
<div
style={{
background: 'rgba(99,102,241,0.10)',
borderRadius: '12px',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '4px',
}}
>
<span className="tertiary-text" style={{ fontSize: '10px' }}>
OUTSTANDING BALANCE
</span>
<span
className="bold tabular"
style={{
fontSize: '22px',
color: 'var(--ion-color-primary)',
}}
>
{formatMoney(selectedCustomer.balance, currency)}
</span>
</div>
)}
<MoneyInput
label="Repayment amount"
value={amount}
onValueChange={(val) => {
setAmount(val);
if (errors.amount) setErrors({ ...errors, amount: '' });
}}
error={errors.amount}
/>
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Repayment date
</IonLabel>
<div className="input-wrapper">
<IonInput
type="date"
value={repaymentDate}
onIonChange={(e) => setRepaymentDate(e.detail.value!)}
/>
</div>
{errors.date && (
<IonLabel
color="danger"
style={{ fontSize: '12px', marginTop: '4px' }}
>
{errors.date}
</IonLabel>
)}
</div>
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Note (optional)
</IonLabel>
<div
className="input-wrapper"
style={{ minHeight: '96px', padding: '8px 16px' }}
>
<IonTextarea
placeholder="e.g. Paid in full"
value={note}
onIonChange={(e) => setNote(e.detail.value!)}
rows={4}
/>
</div>
</div>
{message && (
<div
style={{
background: 'var(--color-danger-soft)',
color: 'var(--ion-color-danger-shade)',
padding: '12px',
borderRadius: '12px',
fontSize: '13px',
}}
>
{message}
</div>
)}
<IonButton
expand="block"
className="bold"
style={{
height: '52px',
'--border-radius': 'var(--radius-pill)',
marginTop: '16px',
}}
disabled={submitting || loadingCustomers}
onClick={handleSave}
>
{submitting ? <IonSpinner name="crescent" /> : 'Save repayment'}
</IonButton>
</div>
)}
</IonContent>
</IonPage>
);
};
export default RepaymentFormPage;
+188
View File
@@ -0,0 +1,188 @@
import React, { useState } from 'react';
import {
IonContent,
IonPage,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonInput,
IonTextarea,
IonSpinner,
IonLabel,
} from '@ionic/react';
import { chevronBackOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { getTodayISO } from '../utils/dates';
import MoneyInput from '../components/MoneyInput';
import { parseMoney } from '../utils/money';
const SaleFormPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [amount, setAmount] = useState('');
const [saleDate, setSaleDate] = useState(getTodayISO());
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [message, setMessage] = useState<string | null>(null);
const validate = () => {
const newErrors: Record<string, string> = {};
const parsedAmount = parseMoney(amount);
if (parsedAmount <= 0) {
newErrors.amount = 'Amount must be greater than 0';
}
if (!saleDate) {
newErrors.date = 'Date is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = async () => {
if (!validate() || !user) return;
setSubmitting(true);
setMessage(null);
try {
const { error } = await supabase.from('sales').insert([
{
user_id: user.id,
amount: parseMoney(amount),
sale_date: saleDate,
note: note.trim() || null,
},
]);
if (error) throw error;
history.goBack();
} catch (err: any) {
console.error('Error saving sale:', err);
setMessage(err.message || 'Could not save sale');
setTimeout(() => setMessage(null), 4000);
} finally {
setSubmitting(false);
}
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f0f4ff' }}>
<IonButtons slot="start">
<IonButton color="dark" onClick={() => history.goBack()}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle className="bold">Add sale</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding" style={{ '--background': '#f0f4ff' }}>
<div className="form-card">
<MoneyInput
label="Sale amount"
value={amount}
onValueChange={(val) => {
setAmount(val);
if (errors.amount) setErrors({ ...errors, amount: '' });
}}
error={errors.amount}
/>
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Sale date
</IonLabel>
<div className="input-wrapper">
<IonInput
type="date"
value={saleDate}
onIonChange={(e) => setSaleDate(e.detail.value!)}
/>
</div>
{errors.date && (
<IonLabel
color="danger"
style={{ fontSize: '12px', marginTop: '4px' }}
>
{errors.date}
</IonLabel>
)}
</div>
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Note (optional)
</IonLabel>
<div
className="input-wrapper"
style={{ minHeight: '96px', padding: '8px 16px' }}
>
<IonTextarea
placeholder="e.g. Morning rush items"
value={note}
onIonChange={(e) => setNote(e.detail.value!)}
rows={4}
/>
</div>
</div>
{message && (
<div
style={{
background: 'var(--color-danger-soft)',
color: 'var(--ion-color-danger-shade)',
padding: '12px',
borderRadius: '12px',
fontSize: '13px',
}}
>
{message}
</div>
)}
<IonButton
expand="block"
className="bold"
style={{
height: '52px',
'--border-radius': 'var(--radius-pill)',
marginTop: '16px',
}}
disabled={submitting}
onClick={handleSave}
>
{submitting ? <IonSpinner name="crescent" /> : 'Save sale'}
</IonButton>
</div>
</IonContent>
</IonPage>
);
};
export default SaleFormPage;
-250
View File
@@ -1,250 +0,0 @@
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;
-296
View File
@@ -1,296 +0,0 @@
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;
+275
View File
@@ -0,0 +1,275 @@
import React, { useState, useRef, useEffect } from 'react';
import {
IonContent,
IonPage,
IonInput,
IonButton,
IonSpinner,
IonIcon,
IonText,
} from '@ionic/react';
import { chevronBackOutline } from 'ionicons/icons';
import { useHistory, useLocation } from 'react-router-dom';
import { supabase } from '../supabase';
interface LocationState {
email: string;
type?: 'signup' | 'recovery';
resent?: boolean;
}
const VerifyEmailPage: React.FC = () => {
const history = useHistory();
const location = useLocation<LocationState>();
const email = location.state?.email || '';
const type = location.state?.type || 'signup';
const [digits, setDigits] = useState<string[]>(['', '', '', '', '', '']);
const [submitting, setSubmitting] = useState(false);
const [resending, setResending] = useState(false);
const [cooldown, setCooldown] = useState(location.state?.resent ? 60 : 0);
const [message, setMessage] = useState<string | null>(null);
const [messageType, setMessageType] = useState<'error' | 'success'>('error');
const inputRefs = useRef<HTMLIonInputElement[]>([]);
useEffect(() => {
if (cooldown > 0) {
const timer = setInterval(() => {
setCooldown((c) => c - 1);
}, 1000);
return () => clearInterval(timer);
}
}, [cooldown]);
const showMessage = (msg: string, type: 'error' | 'success' = 'error') => {
setMessage(msg);
setMessageType(type);
setTimeout(() => setMessage(null), 4000);
};
const handleDigitChange = (index: number, value: string) => {
const newDigits = [...digits];
// Only take the last character (for overwriting)
const val = value.slice(-1);
if (val && !/^\d+$/.test(val)) return;
newDigits[index] = val;
setDigits(newDigits);
if (val && index < 5) {
inputRefs.current[index + 1]?.setFocus();
}
};
const handleKeyDown = (index: number, e: any) => {
if (e.key === 'Backspace' && !digits[index] && index > 0) {
inputRefs.current[index - 1]?.setFocus();
}
};
const handleVerify = async () => {
const code = digits.join('');
if (code.length < 6) {
showMessage('Please enter the 6-digit code');
return;
}
setSubmitting(true);
setMessage(null);
try {
const { data, error } = await supabase.auth.verifyOtp({
email,
token: code,
type: (type === 'recovery' ? 'recovery' : 'signup') as any,
});
if (error) throw error;
if (type === 'recovery') {
history.push('/new-password');
} else {
history.push('/home');
}
} catch (err: any) {
showMessage(err.message || 'Invalid or expired code');
} finally {
setSubmitting(false);
}
};
const handleResend = async () => {
if (cooldown > 0) return;
setResending(true);
try {
const { error } = await supabase.auth.resend({
type: (type === 'recovery' ? 'recovery' : 'signup') as any,
email,
});
if (error) throw error;
showMessage('A new code has been sent', 'success');
setCooldown(60);
} catch (err: any) {
showMessage(err.message || 'Failed to resend code');
} finally {
setResending(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background':
'linear-gradient(160deg, rgba(199,210,254,0.52) 0%, #f0f4ff 58%, #f8fafc 100%)',
}}
>
<div className="auth-shell">
<IonButton
fill="clear"
color="dark"
onClick={() => history.push('/auth')}
style={{
position: 'absolute',
top: 'calc(var(--ion-safe-area-top, 0px) + 8px)',
left: '8px',
'--padding-start': '8px',
'--padding-end': '8px',
zIndex: 10,
}}
>
<div
style={{
background: '#ffffff',
width: '44px',
height: '44px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon icon={chevronBackOutline} />
</div>
</IonButton>
<div style={{ marginTop: '80px' }}>
<h1
className="bold"
style={{ fontSize: '24px', margin: '0 0 8px 0' }}
>
Verify your email
</h1>
<p
className="secondary-text"
style={{ margin: '0', fontSize: '14px' }}
>
We've sent a 6-digit code to <span className="bold">{email}</span>
</p>
</div>
<div className="auth-card">
<div
style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}
>
{digits.map((digit, i) => (
<div
key={i}
style={{
width: '44px',
height: '52px',
background: '#e8edf8',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonInput
ref={(el) => {
inputRefs.current[i] = el!;
}}
value={digit}
onIonInput={(e) => handleDigitChange(i, e.detail.value!)}
onKeyDown={(e) => handleKeyDown(i, e)}
type="text"
inputMode="numeric"
style={{
textAlign: 'center',
fontSize: '22px',
fontWeight: '700',
color: '#111827',
}}
/>
</div>
))}
</div>
{message && (
<div
style={{
background:
messageType === 'error'
? 'var(--color-danger-soft)'
: 'var(--color-success-soft)',
color:
messageType === 'error'
? 'var(--ion-color-danger-shade)'
: 'var(--ion-color-success-shade)',
padding: '12px',
borderRadius: '12px',
fontSize: '13px',
textAlign: 'center',
}}
>
{message}
</div>
)}
<IonButton
expand="block"
className="bold"
style={{
height: '52px',
'--border-radius': 'var(--radius-pill)',
marginTop: '16px',
}}
disabled={submitting}
onClick={handleVerify}
>
{submitting ? <IonSpinner name="crescent" /> : 'Verify code'}
</IonButton>
<div style={{ textAlign: 'center', marginTop: '8px' }}>
<IonText
color={cooldown > 0 ? 'medium' : 'primary'}
className="semibold"
style={{
fontSize: '13px',
cursor: cooldown > 0 ? 'default' : 'pointer',
}}
onClick={handleResend}
>
{resending ? (
<IonSpinner name="dots" />
) : cooldown > 0 ? (
`Resend in ${cooldown}s`
) : (
'Resend code'
)}
</IonText>
</div>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default VerifyEmailPage;
+2 -2
View File
@@ -2,6 +2,6 @@ import { createClient } from '@supabase/supabase-js';
import type { Database } from './database.types'; import type { Database } from './database.types';
export const supabase = createClient<Database>( export const supabase = createClient<Database>(
'https://kxejpmyjfdrbaupauexc.supabase.co', 'https://sxdbfylkgcfmsatqipli.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt4ZWpwbXlqZmRyYmF1cGF1ZXhjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODM2OTkzMzksImV4cCI6MjA5OTI3NTMzOX0.FteANsPY4x605R26bxC6sR4iiYbw_nwG4zrcDYEH83Y', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InN4ZGJmeWxrZ2NmbXNhdHFpcGxpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODExMTI5NjcsImV4cCI6MjA5NjY4ODk2N30.8ZvpjRBskCIH3FFnOkWsxZCOwNMX5txinffgXMDinKI',
); );
+273 -55
View File
@@ -1,73 +1,291 @@
/* Ionic Variables and Color Palette */ /* ShopKeep — Custom Palette & Shared Styles */
:root { :root {
--ion-color-primary: #7b2d26; /** ShopKeep Brand Palette **/
--ion-color-primary-rgb: 123, 45, 38; --ion-color-primary: #6366f1;
--ion-color-primary-rgb: 99, 102, 241;
--ion-color-primary-contrast: #ffffff; --ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255, 255, 255; --ion-color-primary-contrast-rgb: 255, 255, 255;
--ion-color-primary-shade: #6c2721; --ion-color-primary-shade: #5759d4;
--ion-color-primary-tint: #883f3a; --ion-color-primary-tint: #7375f2;
--ion-color-secondary: #b07a3a; --ion-color-secondary: #818cf8;
--ion-color-secondary-rgb: 176, 122, 58; --ion-color-secondary-rgb: 129, 140, 248;
--ion-color-secondary-contrast: #ffffff; --ion-color-secondary-contrast: #ffffff;
--ion-color-secondary-contrast-rgb: 255, 255, 255; --ion-color-secondary-contrast-rgb: 255, 255, 255;
--ion-color-secondary-shade: #9b6b33; --ion-color-secondary-shade: #727bda;
--ion-color-secondary-tint: #b8894e; --ion-color-secondary-tint: #8e98f9;
--ion-background-color: #f8f4ef; --ion-color-tertiary: #c7d2fe;
--color-bg: #f8f4ef; --ion-color-tertiary-rgb: 199, 210, 254;
--ion-color-tertiary-contrast: #111827;
--ion-color-tertiary-contrast-rgb: 17, 24, 39;
--ion-color-tertiary-shade: #afb9e0;
--ion-color-tertiary-tint: #cdd7fe;
--ion-color-success: #3aa67a;
--ion-color-success-rgb: 58, 166, 122;
--ion-color-success-contrast: #ffffff;
--ion-color-success-contrast-rgb: 255, 255, 255;
--ion-color-success-shade: #33926b;
--ion-color-success-tint: #4eb088;
--ion-color-danger: #cf6679;
--ion-color-danger-rgb: 207, 102, 121;
--ion-color-danger-contrast: #ffffff;
--ion-color-danger-contrast-rgb: 255, 255, 255;
--ion-color-danger-shade: #b65a6a;
--ion-color-danger-tint: #d57587;
--ion-color-light: #f3f5fb;
--ion-color-light-rgb: 243, 245, 251;
--ion-color-light-contrast: #111827;
--ion-color-light-contrast-rgb: 17, 24, 39;
--ion-color-light-shade: #d6d8dd;
--ion-color-light-tint: #f4f6fb;
/** App Custom Tokens **/
--color-bg: #f0f4ff;
--color-surface: #ffffff; --color-surface: #ffffff;
--color-surface-raised: #f2ece4; --color-surface-secondary: #e8edf8;
--color-brand: #7b2d26; --color-text-primary: #111827;
--color-brand-soft: rgba(123, 45, 38, 0.1); --color-text-secondary: rgba(17, 24, 39, 0.6);
--color-brand-medium: rgba(123, 45, 38, 0.18); --color-text-tertiary: rgba(17, 24, 39, 0.38);
--color-accent-soft: rgba(99, 102, 241, 0.18);
--color-success-soft: rgba(58, 166, 122, 0.12);
--color-danger-soft: rgba(207, 102, 121, 0.12);
--ion-text-color: #2f221d; /** Radii **/
--ion-text-color-rgb: 47, 34, 29; --radius-pill: 999px;
--color-text-primary: #2f221d; --radius-card: 24px;
--color-text-secondary: rgba(47, 34, 29, 0.64); --radius-inner: 12px;
--color-text-tertiary: rgba(47, 34, 29, 0.42);
--color-border: rgba(47, 34, 29, 0.08);
--ion-color-success: #7b2d26; /** Typography **/
--ion-color-warning: #d29a3f; --font-tabular: 'SF Mono', 'Roboto Mono', Menlo, monospace;
--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 { body {
background-color: var(--color-bg); background: var(--color-bg);
color: var(--color-text-primary);
font-family:
-apple-system, BlinkMacSystemFont, 'Inter', Roboto, Helvetica, Arial,
sans-serif;
} }
.page-shell { /** Shared Auth Styles **/
.auth-shell {
display: flex;
flex-direction: column;
gap: 24px;
padding: calc(var(--ion-safe-area-top, 0px) + 18px) 20px padding: calc(var(--ion-safe-area-top, 0px) + 18px) 20px
calc(var(--ion-safe-area-bottom, 0px) + 28px); calc(var(--ion-safe-area-bottom, 0px) + 28px);
min-height: 100%;
}
.auth-card {
background: var(--color-surface);
border-radius: var(--radius-card);
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
/** Shared Form Styles **/
.form-card {
background: var(--color-surface);
border-radius: var(--radius-card);
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.form-field {
display: flex;
flex-direction: column;
}
.input-wrapper {
background: var(--color-surface-secondary);
border-radius: var(--radius-inner);
padding: 0 16px;
min-height: 52px;
display: flex;
align-items: center;
}
.input-wrapper ion-input,
.input-wrapper ion-textarea,
.input-wrapper ion-select {
--padding-start: 0;
--padding-end: 0;
--placeholder-color: var(--color-text-tertiary);
--placeholder-opacity: 1;
color: var(--color-text-primary);
}
.money-input-container {
display: flex;
flex-direction: column;
}
/** Widget Styles **/
.widget-card {
background: var(--color-surface);
border-radius: var(--radius-card);
padding: 20px;
display: flex;
flex-direction: column;
}
.metric-pair-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.metric-pair-row .widget-card {
flex: 1;
padding: 16px;
}
.skeleton-text {
animation: skeleton-loading 1s linear infinite alternate;
}
@keyframes skeleton-loading {
0% {
background-color: var(--color-surface-secondary);
opacity: 0.5;
}
100% {
background-color: var(--color-surface-secondary);
opacity: 1;
}
}
/** Icon Containers **/
.icon-container {
width: 40px;
height: 40px;
border-radius: var(--radius-inner);
background: var(--color-accent-soft);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--ion-color-primary);
}
.icon-container ion-icon {
font-size: 20px;
}
/** Tab Bar Styling **/
ion-tab-bar {
--background: var(--color-surface);
--border-width: 0;
height: calc(64px + var(--ion-safe-area-bottom));
padding-bottom: var(--ion-safe-area-bottom);
border-top-left-radius: var(--radius-card);
border-top-right-radius: var(--radius-card);
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.04);
}
ion-tab-button {
--color: var(--color-text-tertiary);
--color-selected: var(--ion-color-primary);
}
ion-tab-button ion-label {
font-size: 11px;
font-weight: 600;
margin-top: 4px;
}
ion-tab-button ion-icon {
font-size: 24px;
}
/** Generic Utilities **/
.tabular {
font-family: var(--font-tabular);
}
.semibold {
font-weight: 600;
}
.bold {
font-weight: 700;
}
.tertiary-text {
color: var(--color-text-tertiary);
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.05em;
font-weight: 700;
}
.secondary-text {
color: var(--color-text-secondary);
font-size: 13px;
}
.pill-toggle {
background: var(--color-surface-secondary);
border-radius: var(--radius-pill);
display: flex;
padding: 4px;
gap: 4px;
}
.pill-toggle-btn {
flex: 1;
border-radius: var(--radius-pill);
height: 36px;
border: none;
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary);
background: transparent;
transition: all 0.2s ease;
}
.pill-toggle-btn.active {
background: var(--ion-color-primary);
color: #ffffff;
}
/** Page Spacing **/
.page-padding {
padding: 0 20px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 24px 20px 12px;
}
.section-header h2 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--color-text-primary);
}
.section-header .see-all {
color: var(--ion-color-primary);
font-size: 13px;
font-weight: 600;
text-decoration: none;
}
/** Custom Searchbar **/
.custom-searchbar {
--padding-start: 0;
--padding-end: 0;
} }
-34
View File
@@ -1,34 +0,0 @@
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;
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Date utilities for ShopKeep.
*/
/**
* Returns today's date in YYYY-MM-DD format (local time).
*/
export const getTodayISO = (): string => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
/**
* Formats a date string (YYYY-MM-DD) or ISO string for display.
*/
export const formatDisplayDate = (
dateStr: string,
options: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
}
): string => {
if (!dateStr) return '';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
return new Intl.DateTimeFormat('en-US', options).format(date);
};
/**
* Returns a relative date string (Today, Yesterday, or formatted date).
*/
export const formatRelativeDate = (dateStr: string): string => {
const today = getTodayISO();
const date = new Date(dateStr);
const dateISO = date.toISOString().split('T')[0];
if (dateISO === today) return 'Today';
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayISO = yesterday.toISOString().split('T')[0];
if (dateISO === yesterdayISO) return 'Yesterday';
return formatDisplayDate(dateStr);
};
/**
* Group items by date.
*/
export const groupItemsByDate = <T extends { date: string }>(
items: T[]
): { date: string; items: T[] }[] => {
const groups: Record<string, T[]> = {};
items.forEach((item) => {
if (!groups[item.date]) {
groups[item.date] = [];
}
groups[item.date].push(item);
});
return Object.keys(groups)
.sort((a, b) => b.localeCompare(a)) // Sort by date descending
.map((date) => ({
date,
items: groups[date],
}));
};
+65
View File
@@ -0,0 +1,65 @@
/**
* Money utilities for sanitizing, parsing, and formatting currency values.
*/
/**
* Sanitizes input string to allow only digits and one decimal point.
*/
export const sanitizeMoneyInput = (value: string): string => {
// Remove anything that isn't a digit or a period
let sanitized = value.replace(/[^0-9.]/g, '');
// Ensure only one period exists
const parts = sanitized.split('.');
if (parts.length > 2) {
sanitized = parts[0] + '.' + parts.slice(1).join('');
}
// Limit to 2 decimal places
if (parts.length === 2 && parts[1].length > 2) {
sanitized = parts[0] + '.' + parts[1].slice(0, 2);
}
return sanitized;
};
/**
* Parses a sanitized string into a number.
*/
export const parseMoney = (value: string): number => {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : parsed;
};
/**
* Formats a number as currency.
*/
export const formatMoney = (
amount: number,
currency: string = 'USD',
locale: string = 'en-US'
): string => {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
/**
* Returns the currency symbol for a given currency code.
*/
export const getCurrencySymbol = (
currency: string = 'USD',
locale: string = 'en-US'
): string => {
return (
new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
})
.formatToParts(0)
.find((part) => part.type === 'currency')?.value || '$'
);
};
+1 -11
View File
@@ -17,18 +17,8 @@ const injectRouterBasename: Plugin = {
}, },
}; };
const hideScrollbars: Plugin = {
name: 'hide-scrollbars',
transformIndexHtml(html) {
return html.replace(
'</head>',
'<style>*{scrollbar-width:none!important;-ms-overflow-style:none!important}*::-webkit-scrollbar{display:none!important}</style></head>',
);
},
};
export default defineConfig({ export default defineConfig({
plugins: [injectRouterBasename, hideScrollbars, react()], plugins: [injectRouterBasename, react()],
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 8100, port: 8100,
+62 -71
View File
@@ -324,15 +324,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@capacitor/preferences@npm:^8.0.1":
version: 8.0.1
resolution: "@capacitor/preferences@npm:8.0.1"
peerDependencies:
"@capacitor/core": ">=8.0.0"
checksum: 10c0/cbed0229a685f484c396fd043354688fdeec69264f322180b62c5adb0322561b288a63eb38ee9b38cf78ea7dbc3b9dc8dd8c2e52b54a7f752a1b2f07bfd29f0d
languageName: node
linkType: hard
"@capacitor/push-notifications@npm:^8.1.1": "@capacitor/push-notifications@npm:^8.1.1":
version: 8.1.1 version: 8.1.1
resolution: "@capacitor/push-notifications@npm:8.1.1" resolution: "@capacitor/push-notifications@npm:8.1.1"
@@ -1358,6 +1349,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@protobufjs/inquire@npm:^1.1.2":
version: 1.1.2
resolution: "@protobufjs/inquire@npm:1.1.2"
checksum: 10c0/af69597818a14cac6a00a74cd5b3fb85aa96658b9dbbae73e6d907cb154ebf470953a8c537b3e6095a43de565ec4e6c5b40227c72f4a7d762d34fbec7ac179e7
languageName: node
linkType: hard
"@protobufjs/path@npm:^1.1.2": "@protobufjs/path@npm:^1.1.2":
version: 1.1.2 version: 1.1.2
resolution: "@protobufjs/path@npm:1.1.2" resolution: "@protobufjs/path@npm:1.1.2"
@@ -1373,9 +1371,9 @@ __metadata:
linkType: hard linkType: hard
"@protobufjs/utf8@npm:^1.1.1": "@protobufjs/utf8@npm:^1.1.1":
version: 1.1.2 version: 1.1.1
resolution: "@protobufjs/utf8@npm:1.1.2" resolution: "@protobufjs/utf8@npm:1.1.1"
checksum: 10c0/975c6e2c0319bd50c17531d186b537a88cb4d3205e3ca9297cd842b631d341da0ee3ff16be8656cd2660756dc753b29c8a6a14c0ad8ed602c1f91ddf22e7b3f3 checksum: 10c0/641fc145f00626405e8984b6e90b9edcbcc072ffc82d0647ca3176e09c730b2d022f988e65f011a7a17e2e4d77cde7733643aa10d8ac2bfa30f134dbcad553fd
languageName: node languageName: node
linkType: hard linkType: hard
@@ -1743,70 +1741,70 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@supabase/auth-js@npm:2.110.2": "@supabase/auth-js@npm:2.108.1":
version: 2.110.2 version: 2.108.1
resolution: "@supabase/auth-js@npm:2.110.2" resolution: "@supabase/auth-js@npm:2.108.1"
dependencies: dependencies:
tslib: "npm:2.8.1" tslib: "npm:2.8.1"
checksum: 10c0/3cb1bd3e78498c964ed897032fc09e19a5d8640c1d6b0edbf66a9cad5884547b9107d769f327a556ffab848656fca0de405ba9b961f38b71d44870c28d7b3bd6 checksum: 10c0/8bf1bce42703b63284a0562d5a6588ac879b4fdeb477046a92fa9ba6fa0d75de7d85f4177d389654f68b812e359e9f0c9c3a659e7d37d96aaa2f6eba12d9cff0
languageName: node languageName: node
linkType: hard linkType: hard
"@supabase/functions-js@npm:2.110.2": "@supabase/functions-js@npm:2.108.1":
version: 2.110.2 version: 2.108.1
resolution: "@supabase/functions-js@npm:2.110.2" resolution: "@supabase/functions-js@npm:2.108.1"
dependencies: dependencies:
tslib: "npm:2.8.1" tslib: "npm:2.8.1"
checksum: 10c0/1e6de0a6e6304b1c2593d5ddf232a996688f8c442eb00a704f6b20d2042ae0246240f93c9dc1f5e2a936602c7b9f1ffcdd95921672d2790b30882baa24406b9d checksum: 10c0/da67a82c4a79e049fa3d077f1ca3917a58707fa35df642d39d8b23edf5056a2a2427ea201d3f49bb11eafb06413dfde552a6bf63b2da4865a421d44c690c0fe7
languageName: node languageName: node
linkType: hard linkType: hard
"@supabase/phoenix@npm:0.4.4": "@supabase/phoenix@npm:^0.4.2":
version: 0.4.4 version: 0.4.2
resolution: "@supabase/phoenix@npm:0.4.4" resolution: "@supabase/phoenix@npm:0.4.2"
checksum: 10c0/b4ba930c82b20d412358826aa87ce0735147fd168efb817bfc34e31d78605220947dc7c5844726268ac6bf338f0cd081f9908a7bab87aaff4058cb80bf71ab5b checksum: 10c0/7d426ea70715539d76ef09a5d2364b335f848ffc32cefdc07b67707a9415c654d3f82ebf611851fceb987bdde31f1f77c088a05c40e372b65221801ca502945c
languageName: node languageName: node
linkType: hard linkType: hard
"@supabase/postgrest-js@npm:2.110.2": "@supabase/postgrest-js@npm:2.108.1":
version: 2.110.2 version: 2.108.1
resolution: "@supabase/postgrest-js@npm:2.110.2" resolution: "@supabase/postgrest-js@npm:2.108.1"
dependencies: dependencies:
tslib: "npm:2.8.1" tslib: "npm:2.8.1"
checksum: 10c0/d08f91dbd924d39ce194a7a698ce0a56b61208efd5642368ede0ff9c419cff026da477293b11870c02850bf8e31f69148fce71a051794af83f1175c12b951d9c checksum: 10c0/29168464f44a723175ef510410df36f993581229d617816dfd33eb9bf5378742910fb9c18508040188ba2a16c20988f84d6f722decd79e34190daea35a5374af
languageName: node languageName: node
linkType: hard linkType: hard
"@supabase/realtime-js@npm:2.110.2": "@supabase/realtime-js@npm:2.108.1":
version: 2.110.2 version: 2.108.1
resolution: "@supabase/realtime-js@npm:2.110.2" resolution: "@supabase/realtime-js@npm:2.108.1"
dependencies: dependencies:
"@supabase/phoenix": "npm:0.4.4" "@supabase/phoenix": "npm:^0.4.2"
tslib: "npm:2.8.1" tslib: "npm:2.8.1"
checksum: 10c0/3b6bca9a6e629293b3e41be467a99ac311ebd1ff581979abbd91da646312e7f1f6a2b944ff6d9c409532aa4271f8ad5b23a028546c14d3c0fadcc0a76799af69 checksum: 10c0/bb4cea92714389b6f5f4b626e4c3cbacee2a57c543b71217d3d3032bfe35574cd26fe305bfbde0426230ca3b887e2116c78d47aa37117160b2b9d5ba11564ded
languageName: node languageName: node
linkType: hard linkType: hard
"@supabase/storage-js@npm:2.110.2": "@supabase/storage-js@npm:2.108.1":
version: 2.110.2 version: 2.108.1
resolution: "@supabase/storage-js@npm:2.110.2" resolution: "@supabase/storage-js@npm:2.108.1"
dependencies: dependencies:
iceberg-js: "npm:^0.8.1" iceberg-js: "npm:^0.8.1"
tslib: "npm:2.8.1" tslib: "npm:2.8.1"
checksum: 10c0/a672ac600a5f37ccc196b3a5e8e17f6d57d142b8bed7ab979d69d664d4782dd050e5c3166280b5f1753b18911de7529d43487c02c1e1d1efef20c0b53deb503d checksum: 10c0/5a010b4d3af565b26ceb1d07e3d3f0541c8fe538350931c4fc15d0b560b3e40c354cc66259882a8b378e70dace59bc83dd53849feb48509155b8b342e00724a2
languageName: node languageName: node
linkType: hard linkType: hard
"@supabase/supabase-js@npm:^2.108.1": "@supabase/supabase-js@npm:^2.0.0":
version: 2.110.2 version: 2.108.1
resolution: "@supabase/supabase-js@npm:2.110.2" resolution: "@supabase/supabase-js@npm:2.108.1"
dependencies: dependencies:
"@supabase/auth-js": "npm:2.110.2" "@supabase/auth-js": "npm:2.108.1"
"@supabase/functions-js": "npm:2.110.2" "@supabase/functions-js": "npm:2.108.1"
"@supabase/postgrest-js": "npm:2.110.2" "@supabase/postgrest-js": "npm:2.108.1"
"@supabase/realtime-js": "npm:2.110.2" "@supabase/realtime-js": "npm:2.108.1"
"@supabase/storage-js": "npm:2.110.2" "@supabase/storage-js": "npm:2.108.1"
checksum: 10c0/5ec5432898bc801e871b7aa700429f6e97a77fb70b61de456b6d1e6f84feccab689fdbc4efdfc44fb4c74d48bcde3446c4cf26fcbfa72c787005efe4a72981c2 checksum: 10c0/9b8c3dd7153b4eb3c0bf47296e326c3e57bf77b979a5dc5d18b96a25c041195de942d227d13a5ce83a20203163580204e9102f2346f0a9461de4042b95f69f03
languageName: node languageName: node
linkType: hard linkType: hard
@@ -1875,9 +1873,9 @@ __metadata:
linkType: hard linkType: hard
"@types/google.maps@npm:^3.53.1, @types/google.maps@npm:^3.58.1": "@types/google.maps@npm:^3.53.1, @types/google.maps@npm:^3.58.1":
version: 3.65.2 version: 3.65.1
resolution: "@types/google.maps@npm:3.65.2" resolution: "@types/google.maps@npm:3.65.1"
checksum: 10c0/790aed74128385bfa6c8b41c6bf18b8f1901e6c7b7c806615e904e9093703a8238f8b2504027ebe714e95f9cd5b2a94fd63306f8b71907a23ba24673754843ea checksum: 10c0/9a7da04d9f27be7a3f9a4de2e6dd9271b68b83aa8a6050fc53ec5cc1366669ead4c27185200adf56f07d69c9a2bba72d82a0cf706e41ed4e7e89d98c44c5c6a1
languageName: node languageName: node
linkType: hard linkType: hard
@@ -1905,11 +1903,11 @@ __metadata:
linkType: hard linkType: hard
"@types/node@npm:>=12.12.47, @types/node@npm:>=13.7.0": "@types/node@npm:>=12.12.47, @types/node@npm:>=13.7.0":
version: 26.1.1 version: 25.9.2
resolution: "@types/node@npm:26.1.1" resolution: "@types/node@npm:25.9.2"
dependencies: dependencies:
undici-types: "npm:~8.3.0" undici-types: "npm:>=7.24.0 <7.24.7"
checksum: 10c0/25ac5093195736dd074d5027adbba85617bc951d5a41506ebd3b9073048acb7233613f3822d5f9b9447f9c0841c91f6387c46283916767e3ef79f23917452c46 checksum: 10c0/f14c0d56361febb985eccc45cf0834ee6e2f07c4389a636f3e1a55ebde320077a80bface18c9afd3092f5fa295925502c1a9d55f805efa813f634aa9c941cbac
languageName: node languageName: node
linkType: hard linkType: hard
@@ -2036,14 +2034,13 @@ __metadata:
"@capacitor/haptics": "npm:8.0.2" "@capacitor/haptics": "npm:8.0.2"
"@capacitor/ios": "npm:8.3.4" "@capacitor/ios": "npm:8.3.4"
"@capacitor/keyboard": "npm:8.0.3" "@capacitor/keyboard": "npm:8.0.3"
"@capacitor/preferences": "npm:^8.0.1"
"@capacitor/push-notifications": "npm:^8.1.1" "@capacitor/push-notifications": "npm:^8.1.1"
"@capacitor/status-bar": "npm:8.0.2" "@capacitor/status-bar": "npm:8.0.2"
"@hookform/resolvers": "npm:^3.9.0" "@hookform/resolvers": "npm:^3.9.0"
"@ionic/pwa-elements": "npm:^3.0.0" "@ionic/pwa-elements": "npm:^3.0.0"
"@ionic/react": "npm:8.8.7" "@ionic/react": "npm:8.8.7"
"@ionic/react-router": "npm:8.8.7" "@ionic/react-router": "npm:8.8.7"
"@supabase/supabase-js": "npm:^2.108.1" "@supabase/supabase-js": "npm:^2.0.0"
"@types/katex": "npm:^0.16.0" "@types/katex": "npm:^0.16.0"
"@types/react": "npm:^19.0.0" "@types/react": "npm:^19.0.0"
"@types/react-dom": "npm:^19.0.0" "@types/react-dom": "npm:^19.0.0"
@@ -2389,9 +2386,9 @@ __metadata:
linkType: hard linkType: hard
"fast-equals@npm:^5.2.2": "fast-equals@npm:^5.2.2":
version: 5.4.1 version: 5.4.0
resolution: "fast-equals@npm:5.4.1" resolution: "fast-equals@npm:5.4.0"
checksum: 10c0/18faf5894c39591d61e802b2f574cbc57d2ff2530c71b8cd4e2b4f3c250174002a9aa0b5f4d6f61073a9942a96d71bc7f36514a9b0dc9a2a5cc3395b2a910dbf checksum: 10c0/4fdce3a8f814e78af7296ca4ebe82f4765074a6dfe557ee98c18f5d2958c3de59025fbff7556d65f250165ccef424d37f9952ee159400f8ebe5f2edb704ec80e
languageName: node languageName: node
linkType: hard linkType: hard
@@ -2991,8 +2988,8 @@ __metadata:
linkType: hard linkType: hard
"protobufjs@npm:^7.2.5": "protobufjs@npm:^7.2.5":
version: 7.6.5 version: 7.6.3
resolution: "protobufjs@npm:7.6.5" resolution: "protobufjs@npm:7.6.3"
dependencies: dependencies:
"@protobufjs/aspromise": "npm:^1.1.2" "@protobufjs/aspromise": "npm:^1.1.2"
"@protobufjs/base64": "npm:^1.1.2" "@protobufjs/base64": "npm:^1.1.2"
@@ -3000,12 +2997,13 @@ __metadata:
"@protobufjs/eventemitter": "npm:^1.1.1" "@protobufjs/eventemitter": "npm:^1.1.1"
"@protobufjs/fetch": "npm:^1.1.1" "@protobufjs/fetch": "npm:^1.1.1"
"@protobufjs/float": "npm:^1.0.2" "@protobufjs/float": "npm:^1.0.2"
"@protobufjs/inquire": "npm:^1.1.2"
"@protobufjs/path": "npm:^1.1.2" "@protobufjs/path": "npm:^1.1.2"
"@protobufjs/pool": "npm:^1.1.0" "@protobufjs/pool": "npm:^1.1.0"
"@protobufjs/utf8": "npm:^1.1.1" "@protobufjs/utf8": "npm:^1.1.1"
"@types/node": "npm:>=13.7.0" "@types/node": "npm:>=13.7.0"
long: "npm:^5.3.2" long: "npm:^5.3.2"
checksum: 10c0/863eaca9c6f45bfcfb8787c545f53e9c6696507e328e3981b4643c12d35df7f2826021c10e3fd30bef342c13a8e9d306547bac9c0849ee3bf50f770a12f01dc5 checksum: 10c0/e1132b1ff82684280041550d720398568763ff40928def03e8bb409eaed6edd222e63372e3f52d7c19e5a8e9975333480e26950759086eb5a1f5b5c118da7bf8
languageName: node languageName: node
linkType: hard linkType: hard
@@ -3489,13 +3487,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"undici-types@npm:~8.3.0":
version: 8.3.0
resolution: "undici-types@npm:8.3.0"
checksum: 10c0/c8aa7e2fbebfce519654dafadc0ece59be888d2ccaf180fb4495da875e7b536d2456345c384069c7e6f3e9c9ab7435f074957da306f142343eee86ff8048855a
languageName: node
linkType: hard
"undici@npm:^6.25.0": "undici@npm:^6.25.0":
version: 6.25.0 version: 6.25.0
resolution: "undici@npm:6.25.0" resolution: "undici@npm:6.25.0"
@@ -3699,8 +3690,8 @@ __metadata:
linkType: hard linkType: hard
"yargs@npm:^17.7.2": "yargs@npm:^17.7.2":
version: 17.7.3 version: 17.7.2
resolution: "yargs@npm:17.7.3" resolution: "yargs@npm:17.7.2"
dependencies: dependencies:
cliui: "npm:^8.0.1" cliui: "npm:^8.0.1"
escalade: "npm:^3.1.1" escalade: "npm:^3.1.1"
@@ -3709,7 +3700,7 @@ __metadata:
string-width: "npm:^4.2.3" string-width: "npm:^4.2.3"
y18n: "npm:^5.0.5" y18n: "npm:^5.0.5"
yargs-parser: "npm:^21.1.1" yargs-parser: "npm:^21.1.1"
checksum: 10c0/7a28572f7e785a57886e34fdbddb9b28756dec552e1453d5f6e7cdd00ad8721a4e8c4321d33683f5e61cacb36ad43258adbb48396b71ec4ed14abee0fc0d0c1f checksum: 10c0/ccd7e723e61ad5965fffbb791366db689572b80cca80e0f96aad968dfff4156cd7cd1ad18607afe1046d8241e6fb2d6c08bf7fa7bfb5eaec818735d8feac8f05
languageName: node languageName: node
linkType: hard linkType: hard