Compare commits

..

1 Commits

Author SHA1 Message Date
AppCakes 52125c20dc build: fce83541-8c54-465f-a1fc-71388f99db57 2026-07-13 08:54:39 +00:00
31 changed files with 5210 additions and 4106 deletions
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@ android {
namespace = "io.ionic.starter"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.basedeployer.app"
applicationId "com.afmeaglesnzw.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
+1 -1
View File
@@ -25,7 +25,7 @@
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.basedeployer.app" />
<data android:scheme="com.afmeaglesnzw.app" />
</intent-filter>
</activity>
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Base Deployer</string>
<string name="title_activity_main">Base Deployer</string>
<string name="app_name">AFM.EAGLESNZW</string>
<string name="title_activity_main">AFM.EAGLESNZW</string>
<string name="package_name">io.ionic.starter</string>
<string name="custom_url_scheme">io.ionic.starter</string>
</resources>
+2 -2
View File
@@ -1,8 +1,8 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.basedeployer.app',
appName: 'Base Deployer',
appId: 'com.afmeaglesnzw.app',
appName: 'AFM.EAGLESNZW',
webDir: 'dist',
plugins: {
FirebaseAuthentication: {
+2 -2
View File
@@ -308,7 +308,7 @@
);
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.basedeployer.app;
PRODUCT_BUNDLE_IDENTIFIER = com.afmeaglesnzw.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
@@ -330,7 +330,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.basedeployer.app;
PRODUCT_BUNDLE_IDENTIFIER = com.afmeaglesnzw.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
+7 -3
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Base Deployer</string>
<string>AFM.EAGLESNZW</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -51,12 +51,16 @@
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.basedeployer.app</string>
<string>com.afmeaglesnzw.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.basedeployer.app</string>
<string>com.afmeaglesnzw.app</string>
</array>
</dict>
</array>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to your photo library.</string>
<key>NSCameraUsageDescription</key>
<string>This app needs camera access.</string>
</dict>
</plist>
-5
View File
@@ -25,10 +25,7 @@
"@ionic/pwa-elements": "^3.0.0",
"@ionic/react": "8.8.7",
"@ionic/react-router": "8.8.7",
"@reown/appkit": "^1.8.21",
"@reown/appkit-adapter-wagmi": "^1.8.21",
"@supabase/supabase-js": "^2.108.1",
"@tanstack/react-query": "^5.101.1",
"firebase": "^11.0.0",
"ionicons": "^7.4.0",
"katex": "^0.16.0",
@@ -38,8 +35,6 @@
"react-katex": "^3.0.1",
"react-router": "^5.3.4",
"react-router-dom": "^5.3.4",
"viem": "^2.53.1",
"wagmi": "^3.6.18",
"zod": "^3.24.0",
"zustand": "^5.0.0"
},
+127 -44
View File
@@ -1,20 +1,32 @@
import React, { useEffect } from 'react';
import { Redirect, Route } from 'react-router-dom';
import { IonApp, IonRouterOutlet } from '@ionic/react';
import {
IonApp,
IonIcon,
IonLabel,
IonRouterOutlet,
IonTabBar,
IonTabButton,
IonTabs,
setupIonicReact,
} from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router';
import {
homeOutline,
calendarOutline,
personOutline,
newspaperOutline,
} from 'ionicons/icons';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WagmiProvider } from 'wagmi';
import { WagmiAdapter } from '@reown/appkit-adapter-wagmi';
import { base, type AppKitNetwork } from '@reown/appkit/networks';
import { createAppKit } from '@reown/appkit/react';
import { setStatusBarStyle, Style } from './utils/statusBar';
/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';
/* Basic CSS for apps built with Ionic */
import '@ionic/react/css/normalize.css';
import '@ionic/react/css/structure.css';
import '@ionic/react/css/typography.css';
/* Optional CSS utils that can be commented out */
import '@ionic/react/css/padding.css';
import '@ionic/react/css/float-elements.css';
import '@ionic/react/css/text-alignment.css';
@@ -22,61 +34,132 @@ import '@ionic/react/css/text-transformation.css';
import '@ionic/react/css/flex-utils.css';
import '@ionic/react/css/display.css';
/* Theme variables */
import './theme/variables.css';
import { 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';
// Setup QueryClient
const queryClient = new QueryClient();
import { setStatusBarStyle, Style } from './utils/statusBar';
// Setup AppKit
const projectId =
import.meta.env.VITE_WALLETCONNECT_PROJECT_ID ||
'01e0a2bf8d37317e040edb7878d70804';
setupIonicReact();
const metadata = {
name: 'Base Deployer',
description: 'Base Mainnet GM dApp',
url: 'https://basedeployer.app',
icons: ['https://avatars.githubusercontent.com/u/37784886'],
const SetupProfileRoute: React.FC = () => {
const { user } = useAuth();
if (!user) return <Redirect to="/auth" />;
return <SetupProfile />;
};
const networks = [base as any] as [AppKitNetwork, ...AppKitNetwork[]];
const wagmiAdapter = new WagmiAdapter({
networks,
projectId,
ssr: false,
});
createAppKit({
adapters: [wagmiAdapter],
networks,
projectId,
metadata,
features: {
analytics: true,
},
});
const App: React.FC = () => {
useEffect(() => {
setStatusBarStyle(Style.Dark); // Dark background, light icons
setStatusBarStyle(Style.Dark);
}, []);
return (
<WagmiProvider config={wagmiAdapter.wagmiConfig}>
<QueryClientProvider client={queryClient}>
<IonApp>
<AuthProvider>
<IonReactRouter>
<IonRouterOutlet>
{/* Unprotected Routes */}
<Route path="/auth" component={Auth} exact />
<Route path="/verify-email" component={VerifyEmail} exact />
{/* Profile Setup (requires user, but no profile yet) */}
<Route path="/setup-profile" component={SetupProfileRoute} exact />
{/* Non-tab Protected Routes */}
<ProtectedRoute
path="/event/:id([0-9a-fA-F-]{36})"
component={EventDetail}
exact
/>
<ProtectedRoute
path="/admin/events/new"
component={AdminEventForm}
exact
/>
<ProtectedRoute
path="/admin/events/:id([0-9a-fA-F-]{36})/edit"
component={AdminEventForm}
exact
/>
<ProtectedRoute
path="/news/:id([0-9a-fA-F-]{36})"
component={NewsDetail}
exact
/>
<ProtectedRoute
path="/admin/news/new"
component={AdminNewsForm}
exact
/>
<ProtectedRoute
path="/admin/news/:id([0-9a-fA-F-]{36})"
component={AdminNewsForm}
exact
/>
{/* Tabs */}
<ProtectedRoute
path={['/home', '/events', '/news', '/profile']}
component={() => (
<IonTabs>
<IonRouterOutlet>
<Route path="/home" component={Home} exact />
<Redirect exact from="/" to="/home" />
<Route path="/events" component={Events} exact />
<Route path="/news" component={News} exact />
<Route path="/profile" component={Profile} exact />
</IonRouterOutlet>
<IonTabBar
slot="bottom"
style={{
'--background': 'var(--color-surface)',
'--border': 'none',
color: 'var(--color-text-secondary)',
boxShadow: '0 -2px 10px rgba(47,34,29,0.05)',
}}
>
<IonTabButton tab="home" href="/home">
<IonIcon icon={homeOutline} />
<IonLabel>Home</IonLabel>
</IonTabButton>
<IonTabButton tab="events" href="/events">
<IonIcon icon={calendarOutline} />
<IonLabel>Events</IonLabel>
</IonTabButton>
<IonTabButton tab="news" href="/news">
<IonIcon icon={newspaperOutline} />
<IonLabel>News</IonLabel>
</IonTabButton>
<IonTabButton tab="profile" href="/profile">
<IonIcon icon={personOutline} />
<IonLabel>Profile</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
)}
/>
{/* Root Redirect */}
<Route exact path="/">
<Redirect to="/auth" />
</Route>
</IonRouterOutlet>
</IonReactRouter>
</AuthProvider>
</IonApp>
</QueryClientProvider>
</WagmiProvider>
);
};
+112
View File
@@ -0,0 +1,112 @@
import React from 'react';
import { ChurchEvent } from '../types';
interface EventListCardProps {
event: ChurchEvent;
onClick: () => void;
}
const EventListCard: React.FC<EventListCardProps> = ({ event, onClick }) => {
const categoryName = event.event_categories?.name || 'Event';
const date = new Date(event.start_at);
const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0;
const startTime = hasTime
? date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
: null;
const dateStr = date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
return (
<div
onClick={onClick}
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '18px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
cursor: 'pointer',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div
style={{
background: 'var(--color-brand-soft)',
color: 'var(--color-brand)',
borderRadius: '999px',
padding: '4px 10px',
fontSize: '12px',
fontWeight: '600',
}}
>
{dateStr}
{startTime ? `${startTime}` : ''}
</div>
<div
style={{
fontSize: '12px',
fontWeight: '500',
color: 'var(--color-text-secondary)',
}}
>
{categoryName}
</div>
</div>
<div>
<h3
style={{
margin: '0 0 4px 0',
fontSize: '16px',
fontWeight: '700',
color: 'var(--color-text-primary)',
}}
>
{event.title}
</h3>
<p
style={{
margin: 0,
fontSize: '13px',
fontWeight: '400',
color: 'var(--color-text-secondary)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{event.description}
</p>
</div>
<div
style={{
fontSize: '13px',
fontWeight: '500',
color: 'var(--color-text-primary)',
}}
>
{event.venue}
</div>
</div>
);
};
export default EventListCard;
+100
View File
@@ -0,0 +1,100 @@
import React from 'react';
import { ChurchEvent } from '../types';
import { IonIcon } from '@ionic/react';
import { calendarOutline } from 'ionicons/icons';
interface EventPreviewCardProps {
event: ChurchEvent;
onClick: () => void;
}
const EventPreviewCard: React.FC<EventPreviewCardProps> = ({
event,
onClick,
}) => {
const categoryName = event.event_categories?.name || 'Event';
return (
<div
onClick={onClick}
style={{
display: 'flex',
alignItems: 'center',
padding: '8px 0',
gap: '10px',
background: 'transparent',
cursor: 'pointer',
}}
>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '12px',
background: 'var(--color-brand-soft)',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon
icon={calendarOutline}
style={{ color: 'var(--color-brand)', fontSize: '20px' }}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<h4
style={{
margin: '0 0 2px 0',
fontSize: '15px',
fontWeight: '600',
color: 'var(--color-text-primary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{event.title}
</h4>
<p
style={{
margin: 0,
fontSize: '12px',
fontWeight: '400',
color: 'var(--color-text-secondary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{(() => {
const date = new Date(event.start_at);
const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0;
const timeStr = hasTime
? `${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} • `
: '';
return `${timeStr}${event.day_label}`;
})()}
</p>
</div>
<div
style={{
background: 'var(--color-brand-soft)',
color: 'var(--color-brand)',
borderRadius: '999px',
padding: '4px 10px',
fontSize: '12px',
fontWeight: '600',
flexShrink: 0,
}}
>
{categoryName}
</div>
</div>
);
};
export default EventPreviewCard;
+76
View File
@@ -0,0 +1,76 @@
import React from 'react';
import { IonIcon } from '@ionic/react';
interface HomeStatCardProps {
label: string;
value: string;
caption: string;
icon?: string;
}
const HomeStatCard: React.FC<HomeStatCardProps> = ({
label,
value,
caption,
icon,
}) => {
return (
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '18px',
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '8px',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span
style={{
fontSize: '12px',
fontWeight: '600',
color: 'var(--color-text-secondary)',
}}
>
{label}
</span>
{icon && (
<IonIcon
icon={icon}
style={{ color: 'var(--color-brand)', fontSize: '16px' }}
/>
)}
</div>
<div
style={{
fontSize: '28px',
fontWeight: '400',
color: 'var(--color-text-primary)',
margin: '4px 0 0 0',
}}
>
{value}
</div>
<div
style={{
fontSize: '12px',
fontWeight: '400',
color: 'var(--color-text-secondary)',
}}
>
{caption}
</div>
</div>
);
};
export default HomeStatCard;
+70
View File
@@ -0,0 +1,70 @@
import React from 'react';
import { IonInput } from '@ionic/react';
interface OtpInputRowProps {
digits: string[];
onChange: (index: number, value: string) => void;
onKeyDown: (
index: number,
e: React.KeyboardEvent<HTMLIonInputElement>
) => void;
inputRefs: React.RefObject<HTMLIonInputElement>[];
}
const OtpInputRow: React.FC<OtpInputRowProps> = ({
digits,
onChange,
onKeyDown,
inputRefs,
}) => {
return (
<div
style={{
display: 'flex',
gap: '10px',
justifyContent: 'center',
margin: '0 0 20px 0',
}}
>
{digits.map((digit, index) => (
<div
key={index}
style={{
background: '#ffffff',
borderRadius: '12px',
width: '48px',
height: '56px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 1px 3px rgba(0,0,0,0.05)',
}}
>
<IonInput
ref={inputRefs[index]}
type="tel"
inputMode="numeric"
maxlength={1}
value={digit}
onIonInput={(e) =>
onChange(index, (e.target as HTMLIonInputElement).value as string)
}
onKeyDown={(e) => onKeyDown(index, e)}
style={{
fontSize: '24px',
fontWeight: '600',
color: '#1f2937',
textAlign: 'center',
'--padding-start': '0px',
'--padding-end': '0px',
width: '100%',
height: '100%',
}}
/>
</div>
))}
</div>
);
};
export default OtpInputRow;
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import { Route, Redirect, RouteProps } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
interface ProtectedRouteProps extends RouteProps {
component: React.ComponentType<any>;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
component: Component,
...rest
}) => {
const { user, profile } = useAuth();
return (
<Route
{...rest}
render={(props) => {
if (!user) {
return <Redirect to="/auth" />;
}
if (profile !== undefined && profile === null) {
return <Redirect to="/setup-profile" />;
}
if (profile !== undefined && profile !== null && !profile.full_name) {
return <Redirect to="/setup-profile" />;
}
return <Component {...props} />;
}}
/>
);
};
export default ProtectedRoute;
+137
View File
@@ -0,0 +1,137 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { User } from '@supabase/supabase-js';
import { supabase } from '../supabase';
import { IonSpinner } from '@ionic/react';
export interface Profile {
id: string;
email: string;
full_name: string;
role: string;
ministry_group: string | null;
avatar_url: string | null;
}
interface AuthContextType {
user: User | null;
profile: Profile | null;
refreshProfile: () => Promise<void>;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
user: null,
profile: null,
refreshProfile: async () => {},
signOut: async () => {},
});
const PROFILE_CACHE_KEY = 'church_app_profile_cache';
async function getCachedProfile(): Promise<Profile | null> {
const cached = localStorage.getItem(PROFILE_CACHE_KEY);
if (cached) {
try {
return JSON.parse(cached);
} catch (e) {
return null;
}
}
return null;
}
async function cacheProfile(profile: Profile) {
localStorage.setItem(PROFILE_CACHE_KEY, JSON.stringify(profile));
}
async function clearCachedProfile() {
localStorage.removeItem(PROFILE_CACHE_KEY);
}
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [initializing, setInitializing] = useState(true);
const fetchProfile = async (userId: string) => {
const cached = await getCachedProfile();
if (cached && cached.id === userId) {
setProfile(cached);
}
const { data } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single();
if (data) {
setProfile(data);
await cacheProfile(data);
}
};
const signOut = async () => {
await supabase.auth.signOut();
};
useEffect(() => {
// One-shot bootstrap
supabase.auth.getSession().then(async ({ data }) => {
const currentUser = data.session?.user ?? null;
setUser(currentUser);
if (currentUser) {
await fetchProfile(currentUser.id);
}
setInitializing(false); // only ever called once - IonReactRouter mounts after this
});
// Subsequent events: update state, never touch initializing
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (_event, session) => {
const currentUser = session?.user ?? null;
setUser(currentUser);
if (currentUser) {
await fetchProfile(currentUser.id);
} else {
setProfile(null);
await clearCachedProfile();
}
});
return () => subscription.unsubscribe();
}, []);
if (initializing) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
backgroundColor: '#f5f7f4',
}}
>
<IonSpinner name="crescent" color="primary" />
</div>
);
}
return (
<AuthContext.Provider
value={{
user,
profile,
refreshProfile: () =>
user ? fetchProfile(user.id) : Promise.resolve(),
signOut,
}}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);
+305
View File
@@ -0,0 +1,305 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "14.5"
}
public: {
Tables: {
event_categories: {
Row: {
accent_color: string | null
created_at: string
id: string
name: string
slug: string
}
Insert: {
accent_color?: string | null
created_at?: string
id?: string
name: string
slug: string
}
Update: {
accent_color?: string | null
created_at?: string
id?: string
name?: string
slug?: string
}
Relationships: []
}
events: {
Row: {
audience: string
category_id: string | null
created_at: string
created_by: string | null
day_label: string
description: string
end_at: string | null
id: string
is_featured: boolean
start_at: string
title: string
updated_at: string
venue: string
}
Insert: {
audience?: string
category_id?: string | null
created_at?: string
created_by?: string | null
day_label: string
description?: string
end_at?: string | null
id?: string
is_featured?: boolean
start_at: string
title: string
updated_at?: string
venue: string
}
Update: {
audience?: string
category_id?: string | null
created_at?: string
created_by?: string | null
day_label?: string
description?: string
end_at?: string | null
id?: string
is_featured?: boolean
start_at?: string
title?: string
updated_at?: string
venue?: string
}
Relationships: [
{
foreignKeyName: "events_category_id_fkey"
columns: ["category_id"]
isOneToOne: false
referencedRelation: "event_categories"
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"]
},
]
}
profiles: {
Row: {
avatar_url: string | null
created_at: string
email: string
full_name: string | null
id: string
ministry_group: string | null
role: string
updated_at: string
}
Insert: {
avatar_url?: string | null
created_at?: string
email: string
full_name?: string | null
id: string
ministry_group?: string | null
role?: string
updated_at?: string
}
Update: {
avatar_url?: string | null
created_at?: string
email?: string
full_name?: string | null
id?: string
ministry_group?: string | null
role?: string
updated_at?: string
}
Relationships: []
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {},
},
} as const
+685
View File
@@ -0,0 +1,685 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonSpinner,
IonSelect,
IonSelectOption,
IonDatetime,
IonModal,
IonItem,
IonLabel,
} from '@ionic/react';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import {
chevronBackOutline,
calendarOutline,
timeOutline,
} from 'ionicons/icons';
import { EventCategory, ChurchEvent } from '../types';
const AdminEventForm: React.FC = () => {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [categoryId, setCategoryId] = useState('');
const [audience, setAudience] = useState<'all' | 'youth' | 'children'>('all');
const [venue, setVenue] = useState('');
// Date states
const [startAt, setStartAt] = useState<string>(new Date().toISOString());
const [endAt, setEndAt] = useState<string>('');
const [isFeatured, setIsFeatured] = useState(false);
const [categories, setCategories] = useState<EventCategory[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const { id } = useParams<{ id: string }>();
const isEditMode = !!id;
const history = useHistory();
const { user, profile } = useAuth();
useEffect(() => {
if (profile && profile.role !== 'admin') {
history.replace('/home');
return;
}
const loadData = async () => {
setLoading(true);
// Load categories
const { data: catData, error: catError } = await supabase
.from('event_categories')
.select('*')
.order('name');
if (catError) {
setError(catError.message);
setLoading(false);
return;
}
setCategories(catData as EventCategory[]);
// If edit mode, load event
if (isEditMode) {
const { data: eventData, error: eventError } = await supabase
.from('events')
.select('*')
.eq('id', id)
.single();
if (eventError) {
setError(eventError.message);
} else {
const event = eventData as ChurchEvent;
setTitle(event.title);
setDescription(event.description);
setCategoryId(event.category_id);
setAudience(event.audience);
setVenue(event.venue);
setStartAt(event.start_at);
setEndAt(event.end_at || '');
setIsFeatured(event.is_featured);
}
} else if (catData && catData.length > 0) {
setCategoryId(catData[0].id); // default category
}
setLoading(false);
};
if (profile?.role === 'admin') {
loadData();
}
}, [id, isEditMode, profile, history]);
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const getDayLabel = (dateString: string) => {
const d = new Date(dateString);
return d.toLocaleDateString(undefined, { weekday: 'long' });
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!title || !description || !categoryId || !venue || !startAt) {
showError('Please fill out all required fields');
return;
}
if (!user || profile?.role !== 'admin') {
showError('Unauthorized');
return;
}
setSaving(true);
setError(null);
const dayLabel = getDayLabel(startAt);
const eventPayload = {
title,
description,
category_id: categoryId,
audience,
venue,
start_at: startAt,
end_at: endAt || null,
day_label: dayLabel,
is_featured: isFeatured,
};
if (isEditMode) {
const { error: updateError } = await supabase
.from('events')
.update(eventPayload)
.eq('id', id);
if (updateError) {
showError(updateError.message);
setSaving(false);
return;
}
} else {
const { error: insertError } = await supabase.from('events').insert({
...eventPayload,
created_by: user.id,
});
if (insertError) {
showError(insertError.message);
setSaving(false);
return;
}
}
setSaving(false);
history.goBack();
};
const handleDelete = async () => {
if (!isEditMode) return;
const confirmDelete = window.confirm(
'Are you sure you want to delete this event?'
);
if (!confirmDelete) return;
setSaving(true);
const { error: deleteError } = await supabase
.from('events')
.delete()
.eq('id', id);
if (deleteError) {
showError(deleteError.message);
setSaving(false);
return;
}
history.replace('/events');
};
return (
<IonPage style={{ background: '#f5f7f4' }}>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonButtons slot="start">
<IonButton onClick={() => history.goBack()} disabled={saving}>
<IonIcon
icon={chevronBackOutline}
style={{ color: '#1f2937' }}
slot="icon-only"
/>
</IonButton>
</IonButtons>
<IonTitle
style={{ fontSize: '18px', fontWeight: '700', color: '#1f2937' }}
>
{isEditMode ? 'Edit Event' : 'New Event'}
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent
style={{
'--background': '#f5f7f4',
'--padding-start': '20px',
'--padding-end': '20px',
'--padding-top': '16px',
'--padding-bottom': '24px',
}}
>
{loading ? (
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '40px 0',
}}
>
<IonSpinner name="crescent" color="primary" />
</div>
) : (
<>
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '20px',
margin: '0 0 16px 0',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<h2
style={{
margin: 0,
fontSize: '18px',
fontWeight: '700',
color: '#1f2937',
}}
>
Event Details
</h2>
<p
style={{
margin: '4px 0 0 0',
fontSize: '14px',
fontWeight: '400',
color: '#6b7280',
}}
>
Provide the schedule and venue info for members.
</p>
</div>
{error && (
<div
style={{
background: 'rgba(163, 59, 50, 0.10)',
borderRadius: '12px',
padding: '12px 14px',
marginBottom: '16px',
}}
>
<p
style={{
margin: 0,
fontSize: '13px',
fontWeight: '500',
color: '#a33b32',
lineHeight: '1.4',
}}
>
{error}
</p>
</div>
)}
<form
onSubmit={handleSave}
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '24px',
margin: '0 0 16px 0',
display: 'flex',
flexDirection: 'column',
gap: '16px',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
{/* Title */}
<div>
<label
style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '600',
color: '#1f2937',
}}
>
Event Title
</label>
<input
type="text"
placeholder="e.g. Sunday Service"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
style={{
width: '100%',
background: '#eef2ec',
border: 'none',
borderRadius: '12px',
padding: '0 14px',
height: '48px',
fontSize: '15px',
color: '#1f2937',
outline: 'none',
boxSizing: 'border-box',
}}
/>
</div>
{/* Category */}
<div>
<label
style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '600',
color: '#1f2937',
}}
>
Category
</label>
<div
style={{
background: '#eef2ec',
borderRadius: '12px',
padding: '0 14px',
height: '48px',
display: 'flex',
alignItems: 'center',
}}
>
<IonSelect
value={categoryId}
onIonChange={(e) => setCategoryId(e.detail.value)}
interface="popover"
style={{
width: '100%',
color: '#1f2937',
fontSize: '15px',
}}
>
{categories.map((cat) => (
<IonSelectOption key={cat.id} value={cat.id}>
{cat.name}
</IonSelectOption>
))}
</IonSelect>
</div>
</div>
{/* Audience */}
<div>
<label
style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '600',
color: '#1f2937',
}}
>
Audience
</label>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="button"
onClick={() => setAudience('all')}
style={{
flex: 1,
background: audience === 'all' ? '#2f6b4f' : '#eef2ec',
color: audience === 'all' ? '#ffffff' : '#4b5563',
border: 'none',
borderRadius: '999px',
padding: '12px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
}}
>
All Members
</button>
<button
type="button"
onClick={() => setAudience('youth')}
style={{
flex: 1,
background: audience === 'youth' ? '#2f6b4f' : '#eef2ec',
color: audience === 'youth' ? '#ffffff' : '#4b5563',
border: 'none',
borderRadius: '999px',
padding: '12px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
}}
>
Youth Only
</button>
<button
type="button"
onClick={() => setAudience('children')}
style={{
flex: 1,
background:
audience === 'children' ? '#2f6b4f' : '#eef2ec',
color: audience === 'children' ? '#ffffff' : '#4b5563',
border: 'none',
borderRadius: '999px',
padding: '12px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
}}
>
Children
</button>
</div>
</div>
{/* Venue */}
<div>
<label
style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '600',
color: '#1f2937',
}}
>
Venue
</label>
<input
type="text"
placeholder="e.g. Main Auditorium"
value={venue}
onChange={(e) => setVenue(e.target.value)}
required
style={{
width: '100%',
background: '#eef2ec',
border: 'none',
borderRadius: '12px',
padding: '0 14px',
height: '48px',
fontSize: '15px',
color: '#1f2937',
outline: 'none',
boxSizing: 'border-box',
}}
/>
</div>
{/* Dates */}
<div>
<label
style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '600',
color: '#1f2937',
}}
>
Start Time
</label>
<div
style={{
background: '#eef2ec',
borderRadius: '12px',
padding: '4px 14px',
display: 'flex',
alignItems: 'center',
}}
>
<IonDatetime
presentation="date-time"
value={startAt}
onIonChange={(e) => setStartAt(e.detail.value as string)}
style={{ background: 'transparent' }}
/>
</div>
</div>
<div>
<label
style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '600',
color: '#1f2937',
}}
>
End Time (Optional)
</label>
<div
style={{
background: '#eef2ec',
borderRadius: '12px',
padding: '4px 14px',
display: 'flex',
alignItems: 'center',
}}
>
<IonDatetime
presentation="date-time"
value={endAt}
onIonChange={(e) =>
setEndAt((e.detail.value as string) || '')
}
showClearButton={true}
style={{ background: 'transparent' }}
/>
</div>
</div>
{/* Description */}
<div>
<label
style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '600',
color: '#1f2937',
}}
>
Description
</label>
<textarea
placeholder="Describe the event..."
value={description}
onChange={(e) => setDescription(e.target.value)}
required
style={{
width: '100%',
background: '#eef2ec',
border: 'none',
borderRadius: '12px',
padding: '14px',
minHeight: '120px',
fontSize: '15px',
color: '#1f2937',
outline: 'none',
resize: 'vertical',
boxSizing: 'border-box',
}}
/>
</div>
{/* Featured Toggle */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 0',
}}
>
<span
style={{
fontSize: '14px',
fontWeight: '600',
color: '#1f2937',
}}
>
Feature on Home Screen
</span>
<button
type="button"
onClick={() => setIsFeatured(!isFeatured)}
style={{
width: '44px',
height: '24px',
background: isFeatured ? '#2f6b4f' : '#eef2ec',
borderRadius: '12px',
border: 'none',
position: 'relative',
cursor: 'pointer',
transition: '0.2s',
}}
>
<div
style={{
width: '20px',
height: '20px',
background: '#ffffff',
borderRadius: '50%',
position: 'absolute',
top: '2px',
left: isFeatured ? '22px' : '2px',
transition: '0.2s',
}}
/>
</button>
</div>
<button
type="submit"
disabled={saving}
style={{
background: '#2f6b4f',
border: 'none',
borderRadius: '999px',
padding: '16px',
fontSize: '15px',
fontWeight: '600',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: '8px',
}}
>
{saving ? (
<IonSpinner name="dots" color="light" />
) : isEditMode ? (
'Update Event'
) : (
'Create Event'
)}
</button>
{isEditMode && (
<button
type="button"
onClick={handleDelete}
disabled={saving}
style={{
background: 'rgba(163, 59, 50, 0.10)',
border: 'none',
borderRadius: '999px',
padding: '16px',
fontSize: '15px',
fontWeight: '600',
color: '#a33b32',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Delete Event
</button>
)}
</form>
</>
)}
</IonContent>
</IonPage>
);
};
export default AdminEventForm;
+330
View File
@@ -0,0 +1,330 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonSpinner,
IonItem,
IonLabel,
IonInput,
IonTextarea,
} from '@ionic/react';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { chevronBackOutline, saveOutline, trashOutline } from 'ionicons/icons';
const AdminNewsForm: React.FC = () => {
const history = useHistory();
const { id } = useParams<{ id: string }>();
const isEdit = !!id && id !== 'new';
const { user, profile } = useAuth();
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(isEdit);
const [error, setError] = useState<string | null>(null);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [imageUrl, setImageUrl] = useState('');
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
useEffect(() => {
if (profile?.role !== 'admin') {
history.replace('/profile');
return;
}
if (isEdit) {
loadNewsItem();
}
}, [profile, isEdit]);
const loadNewsItem = async () => {
try {
const { data, error } = await supabase
.from('news')
.select('*')
.eq('id', id)
.single();
if (error) throw error;
if (data) {
setTitle(data.title);
setContent(data.content);
setImageUrl(data.image_url || '');
}
} catch (err: any) {
showError(err.message);
} finally {
setInitialLoading(false);
}
};
const handleSave = async () => {
if (!title.trim() || !content.trim()) {
showError('Title and content are required.');
return;
}
setLoading(true);
try {
if (isEdit) {
const { error } = await supabase
.from('news')
.update({
title,
content,
image_url: imageUrl.trim() || null,
})
.eq('id', id);
if (error) throw error;
} else {
const { error } = await supabase.from('news').insert({
title,
content,
image_url: imageUrl.trim() || null,
created_by: user!.id,
});
if (error) throw error;
}
history.goBack();
} catch (err: any) {
showError(err.message);
setLoading(false);
}
};
const handleDelete = async () => {
if (!window.confirm('Delete this news item?')) return;
setLoading(true);
try {
const { error } = await supabase.from('news').delete().eq('id', id);
if (error) throw error;
history.goBack();
} catch (err: any) {
showError(err.message);
setLoading(false);
}
};
if (initialLoading) {
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonButtons slot="start">
<IonButton onClick={() => history.goBack()}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent style={{ '--background': '#f5f7f4' }}>
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: '40px',
}}
>
<IonSpinner name="crescent" />
</div>
</IonContent>
</IonPage>
);
}
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonButtons slot="start">
<IonButton onClick={() => history.goBack()}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle style={{ fontWeight: '700', fontSize: '18px' }}>
{isEdit ? 'Edit News' : 'Create News'}
</IonTitle>
{isEdit && (
<IonButtons slot="end">
<IonButton onClick={handleDelete} color="danger">
<IonIcon icon={trashOutline} slot="icon-only" />
</IonButton>
</IonButtons>
)}
</IonToolbar>
</IonHeader>
<IonContent style={{ '--background': '#f5f7f4' }}>
<div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
{error && (
<div
style={{
background: '#fee2e2',
color: '#991b1b',
padding: '12px',
borderRadius: '8px',
fontSize: '14px',
marginBottom: '20px',
}}
>
{error}
</div>
)}
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '20px',
display: 'flex',
flexDirection: 'column',
gap: '16px',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<div>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: '600',
color: '#4b5563',
marginBottom: '8px',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Title
</label>
<IonItem
lines="none"
style={{
'--background': '#f9faf9',
'--border-radius': '12px',
'--padding-start': '16px',
}}
>
<IonInput
value={title}
onIonChange={(e) => setTitle(e.detail.value!)}
placeholder="Enter news title"
style={{ fontWeight: '500' }}
/>
</IonItem>
</div>
<div>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: '600',
color: '#4b5563',
marginBottom: '8px',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Image URL (Optional)
</label>
<IonItem
lines="none"
style={{
'--background': '#f9faf9',
'--border-radius': '12px',
'--padding-start': '16px',
}}
>
<IonInput
value={imageUrl}
onIonChange={(e) => setImageUrl(e.detail.value!)}
placeholder="https://example.com/image.jpg"
/>
</IonItem>
</div>
<div>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: '600',
color: '#4b5563',
marginBottom: '8px',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Content
</label>
<IonItem
lines="none"
style={{
'--background': '#f9faf9',
'--border-radius': '12px',
'--padding-start': '16px',
alignItems: 'flex-start',
}}
>
<IonTextarea
value={content}
onIonChange={(e) => setContent(e.detail.value!)}
placeholder="Write your news update here..."
autoGrow
style={{ minHeight: '150px' }}
/>
</IonItem>
</div>
</div>
<button
onClick={handleSave}
disabled={loading}
style={{
width: '100%',
background: '#2f6b4f',
color: '#ffffff',
border: 'none',
borderRadius: '999px',
padding: '16px',
fontSize: '16px',
fontWeight: '600',
marginTop: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.7 : 1,
}}
>
{loading ? (
<IonSpinner
name="crescent"
style={{ width: '24px', height: '24px' }}
/>
) : (
<>
<IonIcon icon={saveOutline} />
Save News
</>
)}
</button>
</div>
</IonContent>
</IonPage>
);
};
export default AdminNewsForm;
+323
View File
@@ -0,0 +1,323 @@
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;
+380
View File
@@ -0,0 +1,380 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonSpinner,
} from '@ionic/react';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { ChurchEvent } from '../types';
import { useAuth } from '../contexts/AuthContext';
import {
chevronBackOutline,
createOutline,
timeOutline,
locationOutline,
calendarOutline,
} from 'ionicons/icons';
const EventDetail: React.FC = () => {
const [event, setEvent] = useState<ChurchEvent | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { id } = useParams<{ id: string }>();
const history = useHistory();
const { profile } = useAuth();
const loadEvent = async () => {
setLoading(true);
setError(null);
const { data, error: fetchError } = await supabase
.from('events')
.select('*, event_categories(*)')
.eq('id', id)
.single();
if (fetchError) {
setError(fetchError.message);
setEvent(null);
} else {
setEvent(data as ChurchEvent);
}
setLoading(false);
};
useEffect(() => {
loadEvent();
}, [id]);
const goToEdit = () => {
if (!event) return;
history.push(`/admin/events/${event.id}/edit`);
};
const isAdmin = profile?.role === 'admin';
return (
<IonPage style={{ background: '#f5f7f4' }}>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonButtons slot="start">
<IonButton onClick={() => history.goBack()}>
<IonIcon
icon={chevronBackOutline}
style={{ color: '#1f2937' }}
slot="icon-only"
/>
</IonButton>
</IonButtons>
<IonTitle
style={{ fontSize: '18px', fontWeight: '700', color: '#1f2937' }}
>
Event Detail
</IonTitle>
{isAdmin && (
<IonButtons slot="end">
<IonButton onClick={goToEdit}>
<IonIcon
icon={createOutline}
style={{ color: '#2f6b4f', fontSize: '20px' }}
slot="icon-only"
/>
</IonButton>
</IonButtons>
)}
</IonToolbar>
</IonHeader>
<IonContent
style={{
'--background': '#f5f7f4',
'--padding-start': '20px',
'--padding-end': '20px',
'--padding-top': '16px',
'--padding-bottom': '24px',
}}
>
{loading ? (
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '40px 0',
}}
>
<IonSpinner name="crescent" color="primary" />
</div>
) : error || !event ? (
<div
style={{
padding: '12px',
background: 'rgba(190,60,60,0.1)',
borderRadius: '12px',
color: '#a33b32',
fontSize: '13px',
}}
>
{error || 'Event not found'}
</div>
) : (
<>
{/* Header Detail Card */}
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '24px',
margin: '0 0 16px 0',
display: 'flex',
flexDirection: 'column',
gap: '10px',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<div style={{ display: 'flex' }}>
<div
style={{
background: 'rgba(47, 107, 79, 0.10)',
color: '#2f6b4f',
borderRadius: '999px',
padding: '5px 12px',
fontSize: '12px',
fontWeight: '600',
}}
>
{event.event_categories?.name || 'Event'}
</div>
</div>
<h1
style={{
margin: 0,
fontSize: '24px',
fontWeight: '700',
color: '#1f2937',
}}
>
{event.title}
</h1>
</div>
{/* Info Rows Card */}
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '20px',
margin: '0 0 16px 0',
display: 'flex',
flexDirection: 'column',
gap: '14px',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
{/* Date Row */}
<div
style={{ display: 'flex', alignItems: 'center', gap: '12px' }}
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '12px',
background: 'rgba(47, 107, 79, 0.12)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={calendarOutline}
style={{ color: '#2f6b4f', fontSize: '18px' }}
/>
</div>
<div>
<div
style={{
fontSize: '12px',
fontWeight: '600',
color: '#7a847f',
}}
>
Date
</div>
<div
style={{
fontSize: '15px',
fontWeight: '600',
color: '#1f2937',
marginTop: '2px',
}}
>
{new Date(event.start_at).toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</div>
</div>
</div>
{/* Time Row */}
{(() => {
const date = new Date(event.start_at);
const hasTime =
date.getHours() !== 0 || date.getMinutes() !== 0;
if (!hasTime) return null;
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '12px',
background: 'rgba(47, 107, 79, 0.12)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={timeOutline}
style={{ color: '#2f6b4f', fontSize: '18px' }}
/>
</div>
<div>
<div
style={{
fontSize: '12px',
fontWeight: '600',
color: '#7a847f',
}}
>
Time
</div>
<div
style={{
fontSize: '15px',
fontWeight: '600',
color: '#1f2937',
marginTop: '2px',
}}
>
{date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
{event.end_at &&
` - ${new Date(event.end_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
</div>
</div>
</div>
);
})()}
{/* Location Row */}
<div
style={{ display: 'flex', alignItems: 'center', gap: '12px' }}
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '12px',
background: 'rgba(47, 107, 79, 0.12)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={locationOutline}
style={{ color: '#2f6b4f', fontSize: '18px' }}
/>
</div>
<div>
<div
style={{
fontSize: '12px',
fontWeight: '600',
color: '#7a847f',
}}
>
Location
</div>
<div
style={{
fontSize: '15px',
fontWeight: '600',
color: '#1f2937',
marginTop: '2px',
}}
>
{event.venue}
</div>
</div>
</div>
</div>
{/* About Card */}
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '20px',
margin: '0 0 16px 0',
display: 'flex',
flexDirection: 'column',
gap: '10px',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<h3
style={{
margin: 0,
fontSize: '16px',
fontWeight: '700',
color: '#1f2937',
}}
>
About
</h3>
<p
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: '#4b5563',
lineHeight: '1.6',
whiteSpace: 'pre-wrap',
}}
>
{event.description}
</p>
</div>
</>
)}
</IonContent>
</IonPage>
);
};
export default EventDetail;
+350
View File
@@ -0,0 +1,350 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonSpinner,
useIonViewWillEnter,
IonIcon,
IonRefresher,
IonRefresherContent,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { ChurchEvent } from '../types';
import EventListCard from '../components/EventListCard';
import {
searchOutline,
filterOutline,
calendarClearOutline,
} from 'ionicons/icons';
const Events: React.FC = () => {
const [allEvents, setAllEvents] = useState<ChurchEvent[]>([]);
const [events, setEvents] = useState<ChurchEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [activeCategory, setActiveCategory] = useState<
'all' | 'weekly' | 'youth' | 'sunday-school'
>('all');
const history = useHistory();
const loadEvents = async (event?: any) => {
if (!event) setLoading(true);
setError(null);
const now = new Date();
now.setDate(now.getDate() - 1);
const dateString = now.toISOString();
const { data, error: fetchError } = await supabase
.from('events')
.select('*, event_categories(*)')
.gte('start_at', dateString)
.order('start_at', { ascending: true });
if (fetchError) {
setError(fetchError.message);
setAllEvents([]);
setEvents([]);
} else {
const fetched = data as ChurchEvent[];
setAllEvents(fetched);
applyFilters(fetched, activeCategory, searchTerm);
}
setLoading(false);
if (event) {
event.detail.complete();
}
};
useIonViewWillEnter(() => {
loadEvents();
});
const applyFilters = (
source: ChurchEvent[],
category: 'all' | 'weekly' | 'youth' | 'sunday-school',
search: string
) => {
let filtered = [...source];
if (category === 'weekly') {
filtered = filtered.filter((e) => e.event_categories?.slug === 'weekly');
} else if (category === 'youth') {
filtered = filtered.filter(
(e) => e.event_categories?.slug === 'youth' || e.audience === 'youth'
);
} else if (category === 'sunday-school') {
filtered = filtered.filter(
(e) => e.event_categories?.slug === 'sunday-school'
);
}
if (search.trim()) {
const term = search.toLowerCase();
filtered = filtered.filter(
(e) =>
e.title.toLowerCase().includes(term) ||
e.description.toLowerCase().includes(term) ||
e.venue.toLowerCase().includes(term)
);
}
setEvents(filtered);
};
const handleCategoryChange = (
category: 'all' | 'weekly' | 'youth' | 'sunday-school'
) => {
setActiveCategory(category);
applyFilters(allEvents, category, searchTerm);
};
const handleSearchChange = (val: string) => {
setSearchTerm(val);
applyFilters(allEvents, activeCategory, val);
};
const openEvent = (id: string) => {
history.push(`/event/${id}`);
};
return (
<IonPage style={{ background: '#f5f7f4' }}>
<IonContent
style={{
'--background': '#f5f7f4',
'--padding-start': '0px',
'--padding-end': '0px',
'--padding-top': '0px',
'--padding-bottom': '24px',
}}
>
<IonRefresher slot="fixed" onIonRefresh={loadEvents}>
<IonRefresherContent />
</IonRefresher>
{/* Top Title Row */}
<div
style={{
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 8px',
margin: '0',
background: 'transparent',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
<h1
style={{
margin: 0,
fontSize: '24px',
fontWeight: '700',
color: '#1f2937',
}}
>
Events
</h1>
<p
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: '#6b7280',
}}
>
Discover what's happening at church
</p>
</div>
{/* Search Row */}
<div style={{ display: 'flex', gap: '8px', margin: '0 20px 12px' }}>
<div
style={{
flex: 1,
background: '#ffffff',
borderRadius: '24px',
height: '44px',
display: 'flex',
alignItems: 'center',
padding: '0 16px',
gap: '10px',
}}
>
<IonIcon
icon={searchOutline}
style={{ color: '#9aa3a0', fontSize: '18px', flexShrink: 0 }}
/>
<input
type="text"
placeholder="Search events..."
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
style={{
flex: 1,
border: 'none',
background: 'transparent',
outline: 'none',
fontSize: '15px',
color: '#1f2937',
}}
/>
</div>
<div
onClick={() => handleCategoryChange('all')}
style={{
width: '44px',
height: '44px',
background: '#ffffff',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
cursor: 'pointer',
}}
>
<IonIcon
icon={filterOutline}
style={{ color: '#2f6b4f', fontSize: '20px' }}
/>
</div>
</div>
{/* Category Chips */}
<div
style={{
display: 'flex',
gap: '8px',
margin: '0 20px 12px',
overflowX: 'auto',
paddingBottom: '4px',
WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none',
}}
>
{['all', 'weekly', 'youth', 'sunday-school'].map((cat) => (
<button
key={cat}
onClick={() => handleCategoryChange(cat as any)}
style={{
background: activeCategory === cat ? '#2f6b4f' : '#ffffff',
color: activeCategory === cat ? '#ffffff' : '#4b5563',
border: 'none',
borderRadius: '999px',
padding: '8px 14px',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
whiteSpace: 'nowrap',
boxShadow:
activeCategory === cat
? 'none'
: '0 1px 2px rgba(0,0,0,0.02)',
}}
>
{cat === 'all'
? 'All Events'
: cat === 'sunday-school'
? 'Sunday School'
: cat.charAt(0).toUpperCase() + cat.slice(1)}
</button>
))}
</div>
{/* Main Content */}
{loading ? (
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '40px 0',
}}
>
<IonSpinner name="crescent" color="primary" />
</div>
) : error ? (
<div
style={{
margin: '0 20px',
padding: '12px',
background: 'rgba(190,60,60,0.1)',
borderRadius: '12px',
color: '#a33b32',
fontSize: '13px',
}}
>
{error}
</div>
) : events.length > 0 ? (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '12px',
margin: '0 20px',
}}
>
{events.map((event) => (
<EventListCard
key={event.id}
event={event}
onClick={() => openEvent(event.id)}
/>
))}
</div>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '60px 20px',
textAlign: 'center',
gap: '12px',
}}
>
<IonIcon
icon={calendarClearOutline}
style={{ fontSize: '44px', color: '#7a847f' }}
/>
<p
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: '#6b7280',
}}
>
No events match this filter.
</p>
<button
onClick={() => {
setSearchTerm('');
handleCategoryChange('all');
}}
style={{
background: 'transparent',
border: 'none',
fontSize: '14px',
fontWeight: '600',
color: '#2f6b4f',
cursor: 'pointer',
marginTop: '4px',
}}
>
Show all events
</button>
</div>
)}
</IonContent>
</IonPage>
);
};
export default Events;
+504 -1069
View File
File diff suppressed because it is too large Load Diff
+229
View File
@@ -0,0 +1,229 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonSpinner,
useIonViewWillEnter,
IonRefresher,
IonRefresherContent,
IonButtons,
IonButton,
IonIcon,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { NewsItem } from '../types';
import { addOutline, newspaperOutline } from 'ionicons/icons';
const News: React.FC = () => {
const history = useHistory();
const { profile } = useAuth();
const [news, setNews] = useState<NewsItem[]>([]);
const [loading, setLoading] = useState(true);
const fetchNews = async () => {
try {
const { data, error } = await supabase
.from('news')
.select('*')
.order('created_at', { ascending: false });
if (error) throw error;
setNews(data || []);
} catch (err) {
console.error('Error fetching news:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
fetchNews();
});
const doRefresh = async (event: any) => {
await fetchNews();
event.detail.complete();
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonTitle
style={{ fontWeight: '700', fontSize: '24px', color: '#1f2937' }}
>
News
</IonTitle>
{profile?.role === 'admin' && (
<IonButtons slot="end">
<IonButton onClick={() => history.push('/admin/news/new')}>
<IonIcon icon={addOutline} style={{ color: '#2f6b4f' }} />
</IonButton>
</IonButtons>
)}
</IonToolbar>
</IonHeader>
<IonContent style={{ '--background': '#f5f7f4' }}>
<IonRefresher slot="fixed" onIonRefresh={doRefresh}>
<IonRefresherContent />
</IonRefresher>
<div style={{ padding: '0 20px 20px' }}>
{loading ? (
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: '40px',
}}
>
<IonSpinner name="crescent" />
</div>
) : news.length === 0 ? (
<div
style={{
textAlign: 'center',
padding: '60px 20px',
color: '#6b7280',
}}
>
<IonIcon
icon={newspaperOutline}
style={{
fontSize: '48px',
color: '#cbd5e1',
marginBottom: '16px',
}}
/>
<p style={{ margin: 0, fontSize: '16px', fontWeight: '500' }}>
No news yet.
</p>
{profile?.role === 'admin' && (
<button
onClick={() => history.push('/admin/news/new')}
style={{
background: '#2f6b4f',
color: '#fff',
border: 'none',
borderRadius: '999px',
padding: '10px 20px',
fontWeight: '600',
marginTop: '20px',
cursor: 'pointer',
}}
>
Create Post
</button>
)}
</div>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
paddingTop: '8px',
}}
>
{news.map((item) => (
<div
key={item.id}
onClick={() => history.push(`/news/${item.id}`)}
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '16px',
display: 'flex',
gap: '16px',
cursor: 'pointer',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
alignItems: 'center',
}}
>
{item.image_url ? (
<img
src={item.image_url}
alt=""
style={{
width: '80px',
height: '80px',
borderRadius: '16px',
objectFit: 'cover',
flexShrink: 0,
background: '#f1f5f9',
}}
/>
) : (
<div
style={{
width: '80px',
height: '80px',
borderRadius: '16px',
background: 'rgba(47, 107, 79, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={newspaperOutline}
style={{ color: '#2f6b4f', fontSize: '28px' }}
/>
</div>
)}
<div
style={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
<div
style={{
fontSize: '12px',
fontWeight: '600',
color: '#9ca3af',
}}
>
{new Date(item.created_at).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
<div
style={{
fontSize: '16px',
fontWeight: '700',
color: '#1f2937',
lineHeight: '1.3',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{item.title}
</div>
</div>
</div>
))}
</div>
)}
</div>
</IonContent>
</IonPage>
);
};
export default News;
+187
View File
@@ -0,0 +1,187 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonButtons,
IonButton,
IonIcon,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { NewsItem } from '../types';
import { chevronBackOutline, createOutline } from 'ionicons/icons';
const NewsDetail: React.FC = () => {
const history = useHistory();
const { id } = useParams<{ id: string }>();
const { profile } = useAuth();
const [news, setNews] = useState<NewsItem | null>(null);
const [loading, setLoading] = useState(true);
useIonViewWillEnter(() => {
fetchNewsItem();
});
const fetchNewsItem = async () => {
try {
const { data, error } = await supabase
.from('news')
.select('*')
.eq('id', id)
.single();
if (error) throw error;
setNews(data);
} catch (err) {
console.error('Error fetching news:', err);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonButtons slot="start">
<IonButton onClick={() => history.goBack()}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent style={{ '--background': '#f5f7f4' }}>
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: '40px',
}}
>
<IonSpinner name="crescent" />
</div>
</IonContent>
</IonPage>
);
}
if (!news) {
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonButtons slot="start">
<IonButton onClick={() => history.goBack()}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent style={{ '--background': '#f5f7f4' }}>
<div
style={{ textAlign: 'center', marginTop: '60px', color: '#6b7280' }}
>
News item not found.
</div>
</IonContent>
</IonPage>
);
}
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonButtons slot="start">
<IonButton onClick={() => history.goBack()}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
{profile?.role === 'admin' && (
<IonButtons slot="end">
<IonButton onClick={() => history.push(`/admin/news/${news.id}`)}>
<IonIcon
icon={createOutline}
slot="icon-only"
style={{ color: '#2f6b4f' }}
/>
</IonButton>
</IonButtons>
)}
</IonToolbar>
</IonHeader>
<IonContent style={{ '--background': '#f5f7f4' }}>
<div
style={{
padding: '0 20px 40px',
maxWidth: '600px',
margin: '0 auto',
}}
>
{news.image_url && (
<div style={{ marginBottom: '24px' }}>
<img
src={news.image_url}
alt=""
style={{
width: '100%',
height: '240px',
objectFit: 'cover',
borderRadius: '24px',
background: '#eef2ec',
}}
/>
</div>
)}
<div
style={{
fontSize: '14px',
fontWeight: '600',
color: '#2f6b4f',
marginBottom: '12px',
}}
>
{new Date(news.created_at).toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</div>
<h1
style={{
margin: '0 0 24px',
fontSize: '28px',
fontWeight: '800',
color: '#1f2937',
lineHeight: '1.2',
}}
>
{news.title}
</h1>
<div
style={{
fontSize: '16px',
color: '#4b5563',
lineHeight: '1.6',
whiteSpace: 'pre-wrap',
}}
>
{news.content}
</div>
</div>
</IonContent>
</IonPage>
);
};
export default NewsDetail;
+521
View File
@@ -0,0 +1,521 @@
import React, { useRef, useState } from 'react';
import { IonPage, IonContent, IonIcon, IonSpinner } from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import {
cameraOutline,
personOutline,
chevronForwardOutline,
settingsOutline,
helpCircleOutline,
} from 'ionicons/icons';
const Profile: React.FC = () => {
const [signingOut, setSigningOut] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [error, setError] = useState<string | null>(null);
const { user, profile, refreshProfile } = useAuth();
const history = useHistory();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const handleSignOut = async () => {
setSigningOut(true);
setError(null);
const { error: signOutError } = await supabase.auth.signOut();
if (signOutError) {
setError(signOutError.message);
setSigningOut(false);
return;
}
history.replace('/auth');
};
const goToCreateEvent = () => {
history.push('/admin/events/new');
};
const initials = profile?.full_name
? profile.full_name
.split(' ')
.map((n) => n[0])
.join('')
.substring(0, 2)
.toUpperCase()
: 'U';
const isAdmin = profile?.role === 'admin';
const avatarUrl = profile?.avatar_url
? `${supabase.storage.from('avatars').getPublicUrl(profile.avatar_url).data.publicUrl}?t=${Date.now()}`
: null;
const showError = (message: string) => {
setError(message);
setTimeout(() => setError(null), 4000);
};
const handleAvatarSelect = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0];
if (!file || !user) {
return;
}
setUploadingAvatar(true);
setError(null);
const extension = file.name.split('.').pop()?.toLowerCase() || 'jpg';
const filePath = `${user.id}/avatar-${Date.now()}.${extension}`;
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(filePath, file);
if (uploadError) {
showError(uploadError.message);
setUploadingAvatar(false);
event.target.value = '';
return;
}
const { error: profileError } = await supabase
.from('profiles')
.update({ avatar_url: filePath, updated_at: new Date().toISOString() })
.eq('id', user.id);
if (profileError) {
showError(profileError.message);
setUploadingAvatar(false);
event.target.value = '';
return;
}
await refreshProfile();
setUploadingAvatar(false);
event.target.value = '';
};
return (
<IonPage style={{ background: '#f5f7f4' }}>
<IonContent
style={{
'--background': '#f5f7f4',
'--padding-start': '0px',
'--padding-end': '0px',
'--padding-top': '0px',
'--padding-bottom': '24px',
}}
>
{/* Top Title Row */}
<div
style={{
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 8px',
margin: '0',
background: 'transparent',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
<h1
style={{
margin: 0,
fontSize: '24px',
fontWeight: '700',
color: '#1f2937',
}}
>
Profile
</h1>
<p
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: '#6b7280',
}}
>
Manage your account
</p>
</div>
{/* Profile Summary Card */}
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '20px',
margin: '0 20px 12px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<div
style={{
position: 'relative',
width: '52px',
height: '52px',
flexShrink: 0,
}}
>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploadingAvatar}
style={{
width: '52px',
height: '52px',
borderRadius: '16px',
background: 'rgba(47, 107, 79, 0.12)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
fontWeight: '700',
color: '#2f6b4f',
flexShrink: 0,
border: 'none',
padding: 0,
overflow: 'hidden',
cursor: uploadingAvatar ? 'default' : 'pointer',
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt={profile?.full_name || 'Profile'}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
initials
)}
</button>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploadingAvatar}
style={{
position: 'absolute',
right: '-4px',
bottom: '-4px',
width: '24px',
height: '24px',
borderRadius: '12px',
border: 'none',
background: '#2f6b4f',
color: '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: uploadingAvatar ? 'default' : 'pointer',
padding: 0,
}}
>
{uploadingAvatar ? (
<IonSpinner
name="crescent"
color="light"
style={{ width: '14px', height: '14px' }}
/>
) : (
<IonIcon icon={cameraOutline} style={{ fontSize: '13px' }} />
)}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleAvatarSelect}
style={{ display: 'none' }}
/>
</div>
<div
style={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: '4px',
}}
>
<div
style={{
fontSize: '18px',
fontWeight: '700',
color: '#1f2937',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{profile?.full_name || 'Member'}
</div>
<div
style={{
fontSize: '13px',
fontWeight: '400',
color: '#6b7280',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{profile?.email || ''}
</div>
</div>
{profile?.ministry_group && (
<div
style={{
background: '#eef2ec',
color: '#4b5563',
borderRadius: '999px',
padding: '4px 10px',
fontSize: '12px',
fontWeight: '600',
}}
>
{profile.ministry_group}
</div>
)}
</div>
{/* Settings / Action List Card */}
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '8px 18px',
margin: '0 20px 12px',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<div
style={{
minHeight: '56px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '12px',
background: '#eef2ec',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={personOutline}
style={{ color: '#2f6b4f', fontSize: '18px' }}
/>
</div>
<div
style={{
flex: 1,
fontSize: '15px',
fontWeight: '600',
color: '#1f2937',
}}
>
Personal Info
</div>
<IonIcon
icon={chevronForwardOutline}
style={{ color: '#9aa3a0', fontSize: '18px' }}
/>
</div>
<div
style={{
minHeight: '56px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '12px',
background: '#eef2ec',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={settingsOutline}
style={{ color: '#2f6b4f', fontSize: '18px' }}
/>
</div>
<div
style={{
flex: 1,
fontSize: '15px',
fontWeight: '600',
color: '#1f2937',
}}
>
Account Settings
</div>
<IonIcon
icon={chevronForwardOutline}
style={{ color: '#9aa3a0', fontSize: '18px' }}
/>
</div>
<div
style={{
minHeight: '56px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '12px',
background: '#eef2ec',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={helpCircleOutline}
style={{ color: '#2f6b4f', fontSize: '18px' }}
/>
</div>
<div
style={{
flex: 1,
fontSize: '15px',
fontWeight: '600',
color: '#1f2937',
}}
>
Help & Support
</div>
<IonIcon
icon={chevronForwardOutline}
style={{ color: '#9aa3a0', fontSize: '18px' }}
/>
</div>
</div>
{/* Admin Shortcut Card */}
{isAdmin && (
<div
style={{
background: 'rgba(47, 107, 79, 0.08)',
borderRadius: '24px',
padding: '18px',
margin: '0 20px 12px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
<div
style={{ fontSize: '14px', fontWeight: '600', color: '#2f6b4f' }}
>
Admin Tools
</div>
<button
onClick={goToCreateEvent}
style={{
background: '#2f6b4f',
border: 'none',
borderRadius: '999px',
padding: '14px 16px',
fontSize: '14px',
fontWeight: '600',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
}}
>
Create New Event
</button>
<button
onClick={() => history.push('/admin/news/new')}
style={{
background: 'rgba(47, 107, 79, 0.1)',
border: 'none',
borderRadius: '999px',
padding: '14px 16px',
fontSize: '14px',
fontWeight: '600',
color: '#2f6b4f',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
}}
>
Manage News
</button>
</div>
)}
{/* Sign Out Button */}
<div style={{ margin: '0 20px 24px 20px' }}>
<button
onClick={handleSignOut}
disabled={signingOut}
style={{
background: '#a33b32',
border: 'none',
borderRadius: '999px',
padding: '16px',
fontSize: '15px',
fontWeight: '600',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
}}
>
{signingOut ? <IonSpinner name="dots" color="light" /> : 'Sign Out'}
</button>
</div>
{error && (
<div
style={{
margin: '0 20px',
padding: '12px',
background: 'rgba(190,60,60,0.1)',
borderRadius: '12px',
color: '#a33b32',
fontSize: '13px',
}}
>
{error}
</div>
)}
</IonContent>
</IonPage>
);
};
export default Profile;
+250
View File
@@ -0,0 +1,250 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonSpinner,
IonSelect,
IonSelectOption,
IonIcon,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { personAddOutline } from 'ionicons/icons';
const SetupProfile: React.FC = () => {
const [fullName, setFullName] = useState('');
const [ministryGroup, setMinistryGroup] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const history = useHistory();
const { user, refreshProfile } = useAuth();
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleProfileSetup = async (e: React.FormEvent) => {
e.preventDefault();
if (!fullName.trim()) {
showError('Please enter your name');
return;
}
if (!user) {
showError('No user session found. Please log in again.');
return;
}
setLoading(true);
setError(null);
const { error: insertError } = await supabase.from('profiles').insert({
id: user.id,
email: user.email ?? '',
full_name: fullName.trim(),
role: 'member',
ministry_group: ministryGroup || null,
});
if (insertError) {
showError(insertError.message);
setLoading(false);
return;
}
await refreshProfile();
history.replace('/home');
setLoading(false);
};
return (
<IonPage style={{ background: '#f5f7f4' }}>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonTitle
style={{ fontSize: '18px', fontWeight: '700', color: '#1f2937' }}
>
Setup Profile
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent
style={{
'--background': '#f5f7f4',
'--padding-start': '20px',
'--padding-end': '20px',
'--padding-top': '16px',
'--padding-bottom': '20px',
}}
>
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '24px',
margin: '0 0 16px 0',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
<div
style={{
background: 'rgba(47, 107, 79, 0.12)',
borderRadius: '12px',
width: '44px',
height: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={personAddOutline}
style={{ fontSize: '20px', color: '#2f6b4f' }}
/>
</div>
<h2
style={{
margin: 0,
fontSize: '20px',
fontWeight: '700',
color: '#1f2937',
}}
>
Welcome to EAGLES ASSEMBLY
</h2>
<p
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: '#6b7280',
}}
>
Please let us know who you are so we can personalize your
experience.
</p>
</div>
<form
onSubmit={handleProfileSetup}
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '24px',
margin: '0 0 16px 0',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<input
type="text"
placeholder="Full Name (e.g. John Doe)"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
style={{
background: '#eef2ec',
border: 'none',
borderRadius: '12px',
padding: '0 14px',
height: '48px',
fontSize: '15px',
color: '#1f2937',
outline: 'none',
}}
/>
<div
style={{
background: '#eef2ec',
borderRadius: '12px',
padding: '0 14px',
height: '48px',
display: 'flex',
alignItems: 'center',
}}
>
<IonSelect
value={ministryGroup}
placeholder="Select Ministry Group (Optional)"
onIonChange={(e) => setMinistryGroup(e.detail.value)}
interface="popover"
style={{
width: '100%',
color: '#1f2937',
fontSize: '15px',
'--placeholder-color': '#9aa3a0',
'--placeholder-opacity': '1',
}}
>
<IonSelectOption value="Youth">Youth</IonSelectOption>
<IonSelectOption value="Choir">Choir</IonSelectOption>
<IonSelectOption value="Ushering">Ushering</IonSelectOption>
<IonSelectOption value="Media">Media</IonSelectOption>
</IonSelect>
</div>
<button
type="submit"
disabled={loading}
style={{
background: '#2f6b4f',
border: 'none',
borderRadius: '999px',
padding: '16px',
fontSize: '15px',
fontWeight: '600',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: '8px',
}}
>
{loading ? (
<IonSpinner name="dots" color="light" />
) : (
'Complete Setup'
)}
</button>
</form>
{error && (
<div
style={{
background: 'rgba(190, 60, 60, 0.10)',
borderRadius: '12px',
padding: '12px 14px',
margin: '0',
}}
>
<p
style={{
margin: 0,
fontSize: '13px',
fontWeight: '500',
color: '#a33b32',
lineHeight: '1.4',
}}
>
{error}
</p>
</div>
)}
</IonContent>
</IonPage>
);
};
export default SetupProfile;
+296
View File
@@ -0,0 +1,296 @@
import React, { useState, useEffect, useRef } from 'react';
import { IonPage, IonContent, IonSpinner, IonIcon } from '@ionic/react';
import { useHistory, useLocation } from 'react-router-dom';
import { supabase } from '../supabase';
import OtpInputRow from '../components/OtpInputRow';
import { checkmarkCircleOutline } from 'ionicons/icons';
const VerifyEmail: React.FC = () => {
const [digits, setDigits] = useState<string[]>(Array(6).fill(''));
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cooldown, setCooldown] = useState(0);
const [verified, setVerified] = useState(false);
const history = useHistory();
const location = useLocation<{ email?: string; resent?: boolean }>();
const email = location.state?.email || '';
const resent = location.state?.resent || false;
const inputRefs = [
useRef<HTMLIonInputElement>(null),
useRef<HTMLIonInputElement>(null),
useRef<HTMLIonInputElement>(null),
useRef<HTMLIonInputElement>(null),
useRef<HTMLIonInputElement>(null),
useRef<HTMLIonInputElement>(null),
];
useEffect(() => {
if (!email) {
history.replace('/auth');
}
if (resent) {
setCooldown(60);
}
}, [email, resent, history]);
useEffect(() => {
if (cooldown > 0) {
const timer = setTimeout(() => setCooldown(cooldown - 1), 1000);
return () => clearTimeout(timer);
}
}, [cooldown]);
const handleDigitChange = (index: number, value: string) => {
const sanitized = value.replace(/[^0-9]/g, '').slice(-1);
const newDigits = [...digits];
newDigits[index] = sanitized;
setDigits(newDigits);
if (sanitized && index < 5) {
inputRefs[index + 1].current?.setFocus();
}
};
const handleKeyDown = (
index: number,
e: React.KeyboardEvent<HTMLIonInputElement>
) => {
if (e.key === 'Backspace') {
if (!digits[index] && index > 0) {
inputRefs[index - 1].current?.setFocus();
}
}
};
const handleVerifyOtp = async () => {
const code = digits.join('');
if (code.length !== 6) {
setError('Please enter all 6 digits');
return;
}
setLoading(true);
setError(null);
const { error } = await supabase.auth.verifyOtp({
email,
token: code,
type: 'email',
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
setVerified(true);
setLoading(false);
};
const handleResend = async () => {
if (cooldown > 0) return;
setLoading(true);
setError(null);
const { error } = await supabase.auth.resend({ type: 'signup', email });
if (error) {
setError(error.message);
setLoading(false);
return;
}
setCooldown(60);
setLoading(false);
};
const goBackToAuth = () => {
history.replace('/auth');
};
return (
<IonPage style={{ background: '#f6f1e8' }}>
<IonContent
style={{
'--background': 'linear-gradient(180deg, #f9f5ee 0%, #f3ede3 100%)',
'--padding-start': '0px',
'--padding-end': '0px',
'--padding-top': '0px',
'--padding-bottom': '0px',
}}
>
<div
style={{
padding:
'calc(var(--ion-safe-area-top, 0px) + 18px) 20px calc(var(--ion-safe-area-bottom, 0px) + 28px)',
minHeight: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
background: 'transparent',
borderRadius: '0px',
padding: '0px',
margin: '0 0 24px 0',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<h1
style={{
margin: 0,
fontSize: '24px',
fontWeight: '700',
color: '#1f2937',
}}
>
Check your email
</h1>
<p
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: '#6b7280',
}}
>
We sent a 6-digit code to {email}.
</p>
</div>
{!verified ? (
<>
<OtpInputRow
digits={digits}
onChange={handleDigitChange}
onKeyDown={handleKeyDown}
inputRefs={inputRefs}
/>
<button
onClick={handleVerifyOtp}
disabled={loading}
style={{
background: '#2f6b4f',
border: 'none',
borderRadius: '999px',
padding: '16px',
fontSize: '15px',
fontWeight: '600',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 0 16px 0',
}}
>
{loading ? <IonSpinner name="dots" color="light" /> : 'Verify'}
</button>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button
onClick={handleResend}
disabled={cooldown > 0 || loading}
style={{
background: 'transparent',
border: 'none',
borderRadius: '999px',
padding: '10px 14px',
fontSize: '14px',
fontWeight: '600',
color: cooldown > 0 ? '#9aa3a0' : '#2f6b4f',
cursor: cooldown > 0 ? 'default' : 'pointer',
}}
>
{cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'}
</button>
</div>
{error && (
<div
style={{
background: 'rgba(190, 60, 60, 0.10)',
borderRadius: '12px',
padding: '12px 14px',
margin: '16px 0 0 0',
}}
>
<p
style={{
margin: 0,
fontSize: '13px',
fontWeight: '500',
color: '#a33b32',
lineHeight: '1.4',
}}
>
{error}
</p>
</div>
)}
</>
) : (
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '24px',
margin: '24px 0 0 0',
display: 'flex',
flexDirection: 'column',
gap: '12px',
alignItems: 'center',
textAlign: 'center',
}}
>
<IonIcon
icon={checkmarkCircleOutline}
style={{ fontSize: '36px', color: '#2f6b4f' }}
/>
<h2
style={{
margin: 0,
fontSize: '18px',
fontWeight: '700',
color: '#1f2937',
}}
>
Email confirmed
</h2>
<p
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: '#6b7280',
}}
>
You can now sign in to your account.
</p>
<button
onClick={goBackToAuth}
style={{
background: '#2f6b4f',
border: 'none',
borderRadius: '999px',
padding: '16px',
fontSize: '15px',
fontWeight: '600',
color: '#ffffff',
cursor: 'pointer',
width: '100%',
marginTop: '12px',
}}
>
Back to Sign In
</button>
</div>
)}
</div>
</IonContent>
</IonPage>
);
};
export default VerifyEmail;
+7
View File
@@ -0,0 +1,7 @@
import { createClient } from '@supabase/supabase-js';
import type { Database } from './database.types';
export const supabase = createClient<Database>(
'https://kxejpmyjfdrbaupauexc.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt4ZWpwbXlqZmRyYmF1cGF1ZXhjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODM2OTkzMzksImV4cCI6MjA5OTI3NTMzOX0.FteANsPY4x605R26bxC6sR4iiYbw_nwG4zrcDYEH83Y',
);
+60 -72
View File
@@ -1,85 +1,73 @@
/* Ionic CSS Variables — customise to match your app's brand */
/* Ionic Variables and Color Palette */
:root {
--ion-color-primary: #2667ff;
--ion-color-primary-rgb: 38, 103, 255;
--ion-color-primary: #7b2d26;
--ion-color-primary-rgb: 123, 45, 38;
--ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255, 255, 255;
--ion-color-primary-shade: #215be0;
--ion-color-primary-tint: #3c76ff;
--ion-color-primary-shade: #6c2721;
--ion-color-primary-tint: #883f3a;
--ion-color-secondary: #7cc7ff;
--ion-color-secondary-rgb: 124, 199, 255;
--ion-color-secondary-contrast: #0a1020;
--ion-color-secondary-contrast-rgb: 10, 16, 32;
--ion-color-secondary-shade: #6dafdf;
--ion-color-secondary-tint: #89cdff;
--ion-color-secondary: #b07a3a;
--ion-color-secondary-rgb: 176, 122, 58;
--ion-color-secondary-contrast: #ffffff;
--ion-color-secondary-contrast-rgb: 255, 255, 255;
--ion-color-secondary-shade: #9b6b33;
--ion-color-secondary-tint: #b8894e;
--ion-color-tertiary: #9fd6ff;
--ion-color-tertiary-rgb: 159, 214, 255;
--ion-color-tertiary-contrast: #0a1020;
--ion-color-tertiary-contrast-rgb: 10, 16, 32;
--ion-color-tertiary-shade: #8cbcb;
--ion-color-tertiary-tint: #a9daff;
--ion-background-color: #f8f4ef;
--color-bg: #f8f4ef;
--color-surface: #ffffff;
--color-surface-raised: #f2ece4;
--color-brand: #7b2d26;
--color-brand-soft: rgba(123, 45, 38, 0.1);
--color-brand-medium: rgba(123, 45, 38, 0.18);
--ion-color-success: #15803d;
--ion-color-success-rgb: 21, 128, 61;
--ion-color-success-contrast: #ffffff;
--ion-color-success-contrast-rgb: 255, 255, 255;
--ion-color-success-shade: #127136;
--ion-color-success-tint: #2c8d50;
--ion-text-color: #2f221d;
--ion-text-color-rgb: 47, 34, 29;
--color-text-primary: #2f221d;
--color-text-secondary: rgba(47, 34, 29, 0.64);
--color-text-tertiary: rgba(47, 34, 29, 0.42);
--color-border: rgba(47, 34, 29, 0.08);
--ion-color-warning: #eab308;
--ion-color-warning-rgb: 234, 179, 8;
--ion-color-warning-contrast: #0a1020;
--ion-color-warning-contrast-rgb: 10, 16, 32;
--ion-color-warning-shade: #ce9d07;
--ion-color-warning-tint: #ecbb21;
--ion-color-danger: #c2410c;
--ion-color-danger-rgb: 194, 65, 12;
--ion-color-danger-contrast: #ffffff;
--ion-color-danger-contrast-rgb: 255, 255, 255;
--ion-color-danger-shade: #ab390a;
--ion-color-danger-tint: #c85424;
--ion-color-dark: #0a1020;
--ion-color-dark-rgb: 10, 16, 32;
--ion-color-dark-contrast: #ffffff;
--ion-color-dark-contrast-rgb: 255, 255, 255;
--ion-color-dark-shade: #090e1c;
--ion-color-dark-tint: #222836;
--ion-color-medium: #6b7a99;
--ion-color-medium-rgb: 107, 122, 153;
--ion-color-medium-contrast: #ffffff;
--ion-color-medium-contrast-rgb: 255, 255, 255;
--ion-color-medium-shade: #5e6b87;
--ion-color-medium-tint: #7a87a3;
--ion-color-light: #f8fbff;
--ion-color-light-rgb: 248, 251, 255;
--ion-color-light-contrast: #0a1020;
--ion-color-light-contrast-rgb: 10, 16, 32;
--ion-color-light-shade: #dadde0;
--ion-color-light-tint: #f9fbff;
--app-background: #0a1020;
--app-surface: #121b36;
--app-surface-secondary: #f7faff;
--app-text-primary: #f8fbff;
--app-text-secondary: rgba(248, 251, 255, 0.7);
--app-text-tertiary: rgba(248, 251, 255, 0.5);
--app-accent-soft: rgba(38, 103, 255, 0.12);
--app-accent-softer: rgba(38, 103, 255, 0.08);
--app-success-soft: rgba(21, 128, 61, 0.12);
--app-shadow: 0 18px 48px rgba(0, 0, 0, 0.4);
--ion-color-success: #7b2d26;
--ion-color-warning: #d29a3f;
--ion-color-danger: #a33b32;
--ion-color-medium: #8e7d73;
--ion-color-light: #f8f4ef;
}
@media (prefers-color-scheme: dark) {
:root {
--ion-background-color: #201513;
--color-bg: #201513;
--color-surface: #2b1c19;
--color-surface-raised: #3a2723;
--color-brand: #c7865f;
--color-brand-soft: rgba(199, 134, 95, 0.14);
--color-brand-medium: rgba(199, 134, 95, 0.22);
--ion-text-color: #f5ebe3;
--ion-text-color-rgb: 245, 235, 227;
--color-text-primary: #f5ebe3;
--color-text-secondary: rgba(245, 235, 227, 0.68);
--color-text-tertiary: rgba(245, 235, 227, 0.44);
--ion-color-primary: #c7865f;
--ion-color-primary-rgb: 199, 134, 95;
--ion-color-primary-contrast: #201513;
--ion-color-primary-contrast-rgb: 32, 21, 19;
--ion-color-primary-shade: #b27754;
--ion-color-primary-tint: #cd9270;
}
}
html,
body {
background: var(--app-background);
color: var(--app-text-primary);
background-color: var(--color-bg);
}
ion-content.home-content {
--background: var(--app-background);
.page-shell {
padding: calc(var(--ion-safe-area-top, 0px) + 18px) 20px
calc(var(--ion-safe-area-bottom, 0px) + 28px);
}
+34
View File
@@ -0,0 +1,34 @@
export interface EventCategory {
id: string;
slug: string;
name: string;
accent_color: string;
created_at: string;
}
export interface ChurchEvent {
id: string;
title: string;
description: string;
category_id: string;
audience: 'all' | 'youth' | 'children';
venue: string;
start_at: string;
end_at: string | null;
day_label: string;
is_featured: boolean;
created_by: string;
created_at: string;
updated_at: string;
event_categories?: EventCategory; // Relation
}
export interface NewsItem {
id: string;
title: string;
content: string;
image_url: string | null;
created_at: string;
updated_at: string;
created_by: string;
}
+46 -2873
View File
File diff suppressed because it is too large Load Diff