Compare commits

..

1 Commits

Author SHA1 Message Date
AppCakes 0119c17e34 build: 91279040-7fca-48e0-9d50-5ce29aae63f6 2026-06-19 06:45:54 +00:00
110 changed files with 7182 additions and 5008 deletions
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@ android {
namespace = "io.ionic.starter" namespace = "io.ionic.starter"
compileSdk = rootProject.ext.compileSdkVersion compileSdk = rootProject.ext.compileSdkVersion
defaultConfig { defaultConfig {
applicationId "com.afmeaglesnzw.app" applicationId "com.inspot.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
+12 -1
View File
@@ -1,6 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -25,9 +30,15 @@
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.afmeaglesnzw.app" /> <data android:scheme="com.inspot.app" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.inspot.app" />
</intent-filter>
</activity> </activity>
<meta-data <meta-data
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<resources> <resources>
<string name="app_name">AFM.EAGLESNZW</string> <string name="app_name">InSpot</string>
<string name="title_activity_main">AFM.EAGLESNZW</string> <string name="title_activity_main">InSpot</string>
<string name="package_name">io.ionic.starter</string> <string name="package_name">io.ionic.starter</string>
<string name="custom_url_scheme">io.ionic.starter</string> <string name="custom_url_scheme">io.ionic.starter</string>
</resources> </resources>
+2 -2
View File
@@ -1,8 +1,8 @@
import type { CapacitorConfig } from '@capacitor/cli'; import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = { const config: CapacitorConfig = {
appId: 'com.afmeaglesnzw.app', appId: 'com.inspot.app',
appName: 'AFM.EAGLESNZW', appName: 'InSpot',
webDir: 'dist', webDir: 'dist',
plugins: { plugins: {
FirebaseAuthentication: { FirebaseAuthentication: {
+2 -2
View File
@@ -308,7 +308,7 @@
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.afmeaglesnzw.app; PRODUCT_BUNDLE_IDENTIFIER = com.inspot.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -330,7 +330,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.afmeaglesnzw.app; PRODUCT_BUNDLE_IDENTIFIER = com.inspot.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
+18 -1
View File
@@ -1,5 +1,7 @@
import UIKit import UIKit
import Capacitor import Capacitor
import FirebaseCore
import FirebaseMessaging
@UIApplicationMain @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
@@ -7,10 +9,25 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow? var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch. FirebaseApp.configure()
return true return true
} }
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
Messaging.messaging().token { token, error in
if let error = error {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
} else if let token = token {
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: token)
}
}
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
}
func applicationWillResignActive(_ application: UIApplication) { func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
@@ -1,14 +1,122 @@
{ {
"images": [ "images": [
{ {
"filename" : "AppIcon-512@2x.png", "idiom": "iphone",
"idiom" : "universal", "scale": "1x",
"platform" : "ios", "size": "20x20",
"size" : "1024x1024" "filename": "icon-20.png"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "20x20",
"filename": "icon-20.png"
},
{
"idiom": "iphone",
"scale": "1x",
"size": "29x29",
"filename": "icon-29.png"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "29x29",
"filename": "icon-29.png"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "40x40",
"filename": "icon-40.png"
},
{
"idiom": "iphone",
"scale": "2x",
"size": "20x20",
"filename": "icon-40.png"
},
{
"idiom": "iphone",
"scale": "2x",
"size": "29x29",
"filename": "icon-58.png"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "29x29",
"filename": "icon-58.png"
},
{
"idiom": "iphone",
"scale": "3x",
"size": "20x20",
"filename": "icon-60.png"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "76x76",
"filename": "icon-76.png"
},
{
"idiom": "iphone",
"scale": "2x",
"size": "40x40",
"filename": "icon-80.png"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "40x40",
"filename": "icon-80.png"
},
{
"idiom": "iphone",
"scale": "3x",
"size": "29x29",
"filename": "icon-87.png"
},
{
"idiom": "iphone",
"scale": "2x",
"size": "60x60",
"filename": "icon-120.png"
},
{
"idiom": "iphone",
"scale": "3x",
"size": "40x40",
"filename": "icon-120.png"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "76x76",
"filename": "icon-152.png"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "83.5x83.5",
"filename": "icon-167.png"
},
{
"idiom": "iphone",
"scale": "3x",
"size": "60x60",
"filename": "icon-180.png"
},
{
"idiom": "ios-marketing",
"scale": "1x",
"size": "1024x1024",
"filename": "icon-1024.png"
} }
], ],
"info": { "info": {
"author" : "xcode", "author": "appsuite",
"version": 1 "version": 1
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 979 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+21 -6
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>AFM.EAGLESNZW</string> <string>InSpot</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -51,16 +51,31 @@
<array> <array>
<dict> <dict>
<key>CFBundleURLName</key> <key>CFBundleURLName</key>
<string>com.afmeaglesnzw.app</string> <string>com.inspot.app</string>
<key>CFBundleURLSchemes</key> <key>CFBundleURLSchemes</key>
<array> <array>
<string>com.afmeaglesnzw.app</string> <string>com.inspot.app</string>
</array>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.inspot.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.inspot.app</string>
</array> </array>
</dict> </dict>
</array> </array>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to your photo library.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>This app needs camera access.</string> <string>InSpot needs camera access to let you capture and share reels of local spots.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>InSpot needs photo library access to let you upload profile photos and share reels from your gallery.</string>
<key>NSMicrophoneUsageDescription</key>
<string>InSpot needs microphone access to record sound for your reels.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>InSpot needs your location to discover nearby spots and social connections.</string>
</dict> </dict>
</plist> </plist>
+1 -1
View File
@@ -18,7 +18,6 @@
"@capacitor/haptics": "8.0.2", "@capacitor/haptics": "8.0.2",
"@capacitor/ios": "8.3.4", "@capacitor/ios": "8.3.4",
"@capacitor/keyboard": "8.0.3", "@capacitor/keyboard": "8.0.3",
"@capacitor/preferences": "^8.0.1",
"@capacitor/push-notifications": "^8.1.1", "@capacitor/push-notifications": "^8.1.1",
"@capacitor/status-bar": "8.0.2", "@capacitor/status-bar": "8.0.2",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
@@ -29,6 +28,7 @@
"firebase": "^11.0.0", "firebase": "^11.0.0",
"ionicons": "^7.4.0", "ionicons": "^7.4.0",
"katex": "^0.16.0", "katex": "^0.16.0",
"lottie-react": "^2.4.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.54.0", "react-hook-form": "^7.54.0",
+88 -101
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React from 'react';
import { Redirect, Route } from 'react-router-dom'; import { Redirect, Route } from 'react-router-dom';
import { import {
IonApp, IonApp,
@@ -8,20 +8,17 @@ import {
IonTabBar, IonTabBar,
IonTabButton, IonTabButton,
IonTabs, IonTabs,
setupIonicReact,
} from '@ionic/react'; } from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router'; import { IonReactRouter } from '@ionic/react-router';
import { import {
homeOutline, flameOutline,
calendarOutline, locationOutline,
chatbubblesOutline,
personOutline, personOutline,
newspaperOutline,
} from 'ionicons/icons'; } from 'ionicons/icons';
/* Core CSS required for Ionic components to work properly */ /* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css'; import '@ionic/react/css/core.css';
/* Basic CSS for apps built with Ionic */
import '@ionic/react/css/normalize.css'; import '@ionic/react/css/normalize.css';
import '@ionic/react/css/structure.css'; import '@ionic/react/css/structure.css';
import '@ionic/react/css/typography.css'; import '@ionic/react/css/typography.css';
@@ -37,111 +34,99 @@ import '@ionic/react/css/display.css';
/* Theme variables */ /* Theme variables */
import './theme/variables.css'; import './theme/variables.css';
import { AuthProvider, useAuth } from './contexts/AuthContext'; /* Pages */
import AuthPage from './pages/AuthPage';
import VerifyEmailPage from './pages/VerifyEmailPage';
import ForgotPasswordPage from './pages/ForgotPasswordPage';
import NewPasswordPage from './pages/NewPasswordPage';
import OnboardingProfilePage from './pages/OnboardingProfilePage';
import IcebreakerPage from './pages/IcebreakerPage';
import HangoutPage from './pages/HangoutPage';
import ReelsPage from './pages/ReelsPage';
import CameraPage from './pages/CameraPage';
import ChatsPage from './pages/ChatsPage';
import ChatThreadPage from './pages/ChatThreadPage';
import ProfilePage from './pages/ProfilePage';
import EditProfilePage from './pages/EditProfilePage';
import NotificationsPage from './pages/NotificationsPage';
import PremiumPage from './pages/PremiumPage';
/* Contexts & Components */
import { AuthProvider } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute'; import ProtectedRoute from './components/ProtectedRoute';
import { isPreviewMode } from './utils/previewMode';
import Auth from './pages/Auth'; const App: React.FC = () => (
import VerifyEmail from './pages/VerifyEmail';
import SetupProfile from './pages/SetupProfile';
import Home from './pages/Home';
import Events from './pages/Events';
import Profile from './pages/Profile';
import EventDetail from './pages/EventDetail';
import AdminEventForm from './pages/AdminEventForm';
import News from './pages/News';
import NewsDetail from './pages/NewsDetail';
import AdminNewsForm from './pages/AdminNewsForm';
import { setStatusBarStyle, Style } from './utils/statusBar';
setupIonicReact();
const SetupProfileRoute: React.FC = () => {
const { user } = useAuth();
if (!user) return <Redirect to="/auth" />;
return <SetupProfile />;
};
const App: React.FC = () => {
useEffect(() => {
setStatusBarStyle(Style.Dark);
}, []);
return (
<IonApp> <IonApp>
<AuthProvider> <AuthProvider>
<IonReactRouter> <IonReactRouter>
<IonRouterOutlet> <IonRouterOutlet>
{/* Unprotected Routes */} {/* Auth Stack */}
<Route path="/auth" component={Auth} exact /> <Route path="/auth" component={AuthPage} exact />
<Route path="/verify-email" component={VerifyEmail} exact /> <Route path="/verify-email" component={VerifyEmailPage} exact />
<Route path="/forgot-password" component={ForgotPasswordPage} exact />
{/* Profile Setup (requires user, but no profile yet) */} <Route path="/new-password" component={NewPasswordPage} exact />
<Route path="/setup-profile" component={SetupProfileRoute} exact />
{/* Non-tab Protected Routes */}
<ProtectedRoute <ProtectedRoute
path="/event/:id([0-9a-fA-F-]{36})" path="/onboarding/profile"
component={EventDetail} component={OnboardingProfilePage}
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 exact
/> />
{/* Tabs */} {/* Detailed Stack (No Tabs) */}
<ProtectedRoute path="/reels/:placeId" component={ReelsPage} exact />
<ProtectedRoute <ProtectedRoute
path={['/home', '/events', '/news', '/profile']} path="/chat/:conversationId"
component={() => ( component={ChatThreadPage}
exact
/>
<ProtectedRoute
path="/profile/edit"
component={EditProfilePage}
exact
/>
<ProtectedRoute path="/premium" component={PremiumPage} exact />
<ProtectedRoute
path="/notifications"
component={NotificationsPage}
exact
/>
<ProtectedRoute path="/camera" component={CameraPage} exact />
{/* Main Tabs */}
<Route
path={['/icebreaker', '/hangout', '/chats', '/profile']}
render={() => (
<IonTabs> <IonTabs>
<IonRouterOutlet> <IonRouterOutlet>
<Route path="/home" component={Home} exact /> <ProtectedRoute
<Route path="/events" component={Events} exact /> path="/icebreaker"
<Route path="/news" component={News} exact /> component={IcebreakerPage}
<Route path="/profile" component={Profile} exact /> exact
/>
<ProtectedRoute
path="/hangout"
component={HangoutPage}
exact
/>
<ProtectedRoute path="/chats" component={ChatsPage} exact />
<ProtectedRoute
path="/profile"
component={ProfilePage}
exact
/>
</IonRouterOutlet> </IonRouterOutlet>
<IonTabBar <IonTabBar slot="bottom">
slot="bottom" <IonTabButton tab="icebreaker" href="/icebreaker">
style={{ <IonIcon icon={flameOutline} />
'--background': 'var(--color-surface)', <IonLabel>Icebreaker</IonLabel>
'--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>
<IonTabButton tab="events" href="/events"> <IonTabButton tab="hangout" href="/hangout">
<IonIcon icon={calendarOutline} /> <IonIcon icon={locationOutline} />
<IonLabel>Events</IonLabel> <IonLabel>Hangout</IonLabel>
</IonTabButton> </IonTabButton>
<IonTabButton tab="news" href="/news"> <IonTabButton tab="chats" href="/chats">
<IonIcon icon={newspaperOutline} /> <IonIcon icon={chatbubblesOutline} />
<IonLabel>News</IonLabel> <IonLabel>Chats</IonLabel>
</IonTabButton> </IonTabButton>
<IonTabButton tab="profile" href="/profile"> <IonTabButton tab="profile" href="/profile">
<IonIcon icon={personOutline} /> <IonIcon icon={personOutline} />
@@ -152,15 +137,17 @@ const App: React.FC = () => {
)} )}
/> />
{/* Root Redirect */} <Route
<Route exact path="/"> exact
<Redirect to="/auth" /> path="/"
</Route> render={() => (
<Redirect to={isPreviewMode() ? '/icebreaker' : '/auth'} />
)}
/>
</IonRouterOutlet> </IonRouterOutlet>
</IonReactRouter> </IonReactRouter>
</AuthProvider> </AuthProvider>
</IonApp> </IonApp>
); );
};
export default App; export default App;
View File
Binary file not shown.
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

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

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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

After

Width:  |  Height:  |  Size: 148 KiB

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

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

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

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+70
View File
@@ -0,0 +1,70 @@
import React from 'react';
import { getStorageUrl, getInitials, getInitialsColor } from '../utils/storage';
interface AvatarProps {
src?: string | null;
name?: string | null;
size?: number;
bucket?: string;
className?: string;
style?: React.CSSProperties;
}
const Avatar: React.FC<AvatarProps> = ({
src,
name,
size = 48,
bucket = 'avatars',
className = '',
style = {},
}) => {
const url =
src?.startsWith('/src/assets/') || src?.startsWith('/assets/')
? src
: getStorageUrl(bucket, src);
const containerStyle: React.CSSProperties = {
width: size,
height: size,
borderRadius: '50%',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
background: url ? 'transparent' : getInitialsColor(name || null),
...style,
};
if (url) {
return (
<div className={className} style={containerStyle}>
<img
src={url}
alt={name || 'Avatar'}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
);
}
return (
<div className={className} style={containerStyle}>
<span
style={{
color: '#ffffff',
fontSize: size * 0.4,
fontWeight: 700,
letterSpacing: '-0.02em',
}}
>
{getInitials(name || null)}
</span>
</div>
);
};
export default Avatar;
+84
View File
@@ -0,0 +1,84 @@
import React from 'react';
import { IonButton, IonIcon } from '@ionic/react';
interface EmptyStateProps {
icon: string;
title: string;
message: string;
ctaText?: string;
onCtaClick?: () => void;
style?: React.CSSProperties;
}
const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
message,
ctaText,
onCtaClick,
style = {},
}) => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 20px',
textAlign: 'center',
minHeight: '300px',
...style,
}}
>
<div
style={{
width: '80px',
height: '80px',
borderRadius: '24px',
background: 'var(--color-surface-raised)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '20px',
}}
>
<IonIcon
icon={icon}
style={{ fontSize: '40px', color: 'var(--ion-color-primary)' }}
/>
</div>
<h3
style={{
fontSize: '20px',
fontWeight: 700,
margin: '0 0 8px',
color: 'var(--color-text-primary)',
}}
>
{title}
</h3>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: '0 0 24px',
lineHeight: 1.5,
}}
>
{message}
</p>
{ctaText && onCtaClick && (
<IonButton
expand="block"
onClick={onCtaClick}
style={{ '--border-radius': 'var(--radius-pill)', minWidth: '200px' }}
>
{ctaText}
</IonButton>
)}
</div>
);
};
export default EmptyState;
-112
View File
@@ -1,112 +0,0 @@
import React from 'react';
import { ChurchEvent } from '../types';
interface EventListCardProps {
event: ChurchEvent;
onClick: () => void;
}
const EventListCard: React.FC<EventListCardProps> = ({ event, onClick }) => {
const categoryName = event.event_categories?.name || 'Event';
const date = new Date(event.start_at);
const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0;
const startTime = hasTime
? date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
: null;
const dateStr = date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
return (
<div
onClick={onClick}
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '18px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
cursor: 'pointer',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div
style={{
background: 'var(--color-brand-soft)',
color: 'var(--color-brand)',
borderRadius: '999px',
padding: '4px 10px',
fontSize: '12px',
fontWeight: '600',
}}
>
{dateStr}
{startTime ? `${startTime}` : ''}
</div>
<div
style={{
fontSize: '12px',
fontWeight: '500',
color: 'var(--color-text-secondary)',
}}
>
{categoryName}
</div>
</div>
<div>
<h3
style={{
margin: '0 0 4px 0',
fontSize: '16px',
fontWeight: '700',
color: 'var(--color-text-primary)',
}}
>
{event.title}
</h3>
<p
style={{
margin: 0,
fontSize: '13px',
fontWeight: '400',
color: 'var(--color-text-secondary)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{event.description}
</p>
</div>
<div
style={{
fontSize: '13px',
fontWeight: '500',
color: 'var(--color-text-primary)',
}}
>
{event.venue}
</div>
</div>
);
};
export default EventListCard;
-100
View File
@@ -1,100 +0,0 @@
import React from 'react';
import { ChurchEvent } from '../types';
import { IonIcon } from '@ionic/react';
import { calendarOutline } from 'ionicons/icons';
interface EventPreviewCardProps {
event: ChurchEvent;
onClick: () => void;
}
const EventPreviewCard: React.FC<EventPreviewCardProps> = ({
event,
onClick,
}) => {
const categoryName = event.event_categories?.name || 'Event';
return (
<div
onClick={onClick}
style={{
display: 'flex',
alignItems: 'center',
padding: '8px 0',
gap: '10px',
background: 'transparent',
cursor: 'pointer',
}}
>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '12px',
background: 'var(--color-brand-soft)',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon
icon={calendarOutline}
style={{ color: 'var(--color-brand)', fontSize: '20px' }}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<h4
style={{
margin: '0 0 2px 0',
fontSize: '15px',
fontWeight: '600',
color: 'var(--color-text-primary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{event.title}
</h4>
<p
style={{
margin: 0,
fontSize: '12px',
fontWeight: '400',
color: 'var(--color-text-secondary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{(() => {
const date = new Date(event.start_at);
const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0;
const timeStr = hasTime
? `${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} • `
: '';
return `${timeStr}${event.day_label}`;
})()}
</p>
</div>
<div
style={{
background: 'var(--color-brand-soft)',
color: 'var(--color-brand)',
borderRadius: '999px',
padding: '4px 10px',
fontSize: '12px',
fontWeight: '600',
flexShrink: 0,
}}
>
{categoryName}
</div>
</div>
);
};
export default EventPreviewCard;
-76
View File
@@ -1,76 +0,0 @@
import React from 'react';
import { IonIcon } from '@ionic/react';
interface HomeStatCardProps {
label: string;
value: string;
caption: string;
icon?: string;
}
const HomeStatCard: React.FC<HomeStatCardProps> = ({
label,
value,
caption,
icon,
}) => {
return (
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '18px',
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '8px',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span
style={{
fontSize: '12px',
fontWeight: '600',
color: 'var(--color-text-secondary)',
}}
>
{label}
</span>
{icon && (
<IonIcon
icon={icon}
style={{ color: 'var(--color-brand)', fontSize: '16px' }}
/>
)}
</div>
<div
style={{
fontSize: '28px',
fontWeight: '400',
color: 'var(--color-text-primary)',
margin: '4px 0 0 0',
}}
>
{value}
</div>
<div
style={{
fontSize: '12px',
fontWeight: '400',
color: 'var(--color-text-secondary)',
}}
>
{caption}
</div>
</div>
);
};
export default HomeStatCard;
+97
View File
@@ -0,0 +1,97 @@
import React, { useRef, useEffect } from 'react';
interface OtpInputProps {
value: string;
onChange: (value: string) => void;
length?: number;
disabled?: boolean;
}
const OtpInput: React.FC<OtpInputProps> = ({
value,
onChange,
length = 6,
disabled = false,
}) => {
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement>,
index: number
) => {
const val = e.target.value;
if (!val) return;
const newOtp = value.split('');
newOtp[index] = val.slice(-1);
const combined = newOtp.join('');
onChange(combined);
if (val && index < length - 1) {
inputRefs.current[index + 1]?.focus();
}
};
const handleKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>,
index: number
) => {
if (e.key === 'Backspace' && !value[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const pastedData = e.clipboardData.getData('text').slice(0, length);
if (/^\d+$/.test(pastedData)) {
onChange(pastedData);
inputRefs.current[Math.min(pastedData.length, length - 1)]?.focus();
}
};
return (
<div
style={{
display: 'flex',
gap: '8px',
justifyContent: 'center',
margin: '24px 0',
}}
>
{Array.from({ length }).map((_, i) => (
<input
key={i}
ref={(el) => {
inputRefs.current[i] = el;
}}
type="text"
inputMode="numeric"
pattern="\d*"
maxLength={1}
value={value[i] || ''}
onChange={(e) => handleChange(e, i)}
onKeyDown={(e) => handleKeyDown(e, i)}
onPaste={handlePaste}
disabled={disabled}
style={{
width: '44px',
height: '52px',
textAlign: 'center',
fontSize: '22px',
fontWeight: 700,
borderRadius: '12px',
border: '2px solid transparent',
background: 'var(--color-surface)',
color: 'var(--color-text-primary)',
outline: 'none',
transition: 'border-color 0.2s',
...(value[i] ? { borderColor: 'var(--ion-color-primary)' } : {}),
}}
/>
))}
</div>
);
};
export default OtpInput;
-70
View File
@@ -1,70 +0,0 @@
import React from 'react';
import { IonInput } from '@ionic/react';
interface OtpInputRowProps {
digits: string[];
onChange: (index: number, value: string) => void;
onKeyDown: (
index: number,
e: React.KeyboardEvent<HTMLIonInputElement>
) => void;
inputRefs: React.RefObject<HTMLIonInputElement>[];
}
const OtpInputRow: React.FC<OtpInputRowProps> = ({
digits,
onChange,
onKeyDown,
inputRefs,
}) => {
return (
<div
style={{
display: 'flex',
gap: '10px',
justifyContent: 'center',
margin: '0 0 20px 0',
}}
>
{digits.map((digit, index) => (
<div
key={index}
style={{
background: '#ffffff',
borderRadius: '12px',
width: '48px',
height: '56px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 1px 3px rgba(0,0,0,0.05)',
}}
>
<IonInput
ref={inputRefs[index]}
type="tel"
inputMode="numeric"
maxlength={1}
value={digit}
onIonInput={(e) =>
onChange(index, (e.target as HTMLIonInputElement).value as string)
}
onKeyDown={(e) => onKeyDown(index, e)}
style={{
fontSize: '24px',
fontWeight: '600',
color: '#1f2937',
textAlign: 'center',
'--padding-start': '0px',
'--padding-end': '0px',
width: '100%',
height: '100%',
}}
/>
</div>
))}
</div>
);
};
export default OtpInputRow;
+5 -12
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Route, Redirect, RouteProps } from 'react-router-dom'; import { Route, Redirect, RouteProps } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { isPreviewMode } from '../utils/previewMode';
interface ProtectedRouteProps extends RouteProps { interface ProtectedRouteProps extends RouteProps {
component: React.ComponentType<any>; component: React.ComponentType<any>;
@@ -10,23 +11,15 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
component: Component, component: Component,
...rest ...rest
}) => { }) => {
const { user, profile } = useAuth(); const { user } = useAuth();
const previewMode = isPreviewMode();
return ( return (
<Route <Route
{...rest} {...rest}
render={(props) => { render={(props) =>
if (!user) { user || previewMode ? <Component {...props} /> : <Redirect to="/auth" />
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} />;
}}
/> />
); );
}; };
+68
View File
@@ -0,0 +1,68 @@
import React from 'react';
import { IonSearchbar, IonButton, IonIcon } from '@ionic/react';
import { optionsOutline } from 'ionicons/icons';
interface SearchRowProps {
value: string;
onChange: (e: any) => void;
onFilterClick?: () => void;
placeholder?: string;
}
const SearchRow: React.FC<SearchRowProps> = ({
value,
onChange,
onFilterClick,
placeholder = 'Search...',
}) => {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
margin: '0 20px 14px',
}}
>
<IonSearchbar
value={value}
onIonInput={onChange}
placeholder={placeholder}
style={{
padding: 0,
'--border-radius': 'var(--radius-pill)',
'--background': 'rgba(255,255,255,0.06)',
'--color': 'var(--color-text-primary)',
'--placeholder-color': 'rgba(255,255,255,0.45)',
'--icon-color': 'rgba(255,255,255,0.75)',
'--clear-button-color': 'rgba(255,255,255,0.55)',
}}
/>
{onFilterClick && (
<IonButton
onClick={onFilterClick}
style={{
'--background': 'rgba(255,255,255,0.06)',
'--color': 'var(--color-text-primary)',
'--border-radius': '12px',
width: '44px',
height: '44px',
margin: 0,
flexShrink: 0,
'--padding-start': 0,
'--padding-end': 0,
'--box-shadow': 'none',
}}
>
<IonIcon
icon={optionsOutline}
slot="icon-only"
style={{ fontSize: '20px' }}
/>
</IonButton>
)}
</div>
);
};
export default SearchRow;
+44 -74
View File
@@ -1,109 +1,79 @@
import React, { createContext, useContext, useEffect, useState } from 'react'; import React, { createContext, useContext, useEffect, useState } from 'react';
import { User } from '@supabase/supabase-js'; import { User, Session } from '@supabase/supabase-js';
import { supabase } from '../supabase'; import { supabase } from '../supabase';
import { Database } from '../database.types';
import { IonSpinner } from '@ionic/react'; import { IonSpinner } from '@ionic/react';
export interface Profile { type Profile = Database['public']['Tables']['profiles']['Row'];
id: string;
email: string;
full_name: string;
role: string;
ministry_group: string | null;
avatar_url: string | null;
}
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
session: Session | null;
profile: Profile | null; profile: Profile | null;
loading: boolean;
refreshProfile: () => Promise<void>; refreshProfile: () => Promise<void>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType>({ const AuthContext = createContext<AuthContextType | undefined>(undefined);
user: null,
profile: null,
refreshProfile: async () => {},
signOut: async () => {},
});
const PROFILE_CACHE_KEY = 'church_app_profile_cache';
async function getCachedProfile(): Promise<Profile | null> {
const cached = localStorage.getItem(PROFILE_CACHE_KEY);
if (cached) {
try {
return JSON.parse(cached);
} catch (e) {
return null;
}
}
return null;
}
async function cacheProfile(profile: Profile) {
localStorage.setItem(PROFILE_CACHE_KEY, JSON.stringify(profile));
}
async function clearCachedProfile() {
localStorage.removeItem(PROFILE_CACHE_KEY);
}
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
children, children,
}) => { }) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [profile, setProfile] = useState<Profile | null>(null); const [profile, setProfile] = useState<Profile | null>(null);
const [initializing, setInitializing] = useState(true); const [loading, setLoading] = useState(true);
const fetchProfile = async (userId: string) => { const refreshProfile = async (userId?: string) => {
const cached = await getCachedProfile(); const id = userId || user?.id;
if (cached && cached.id === userId) { if (!id) return;
setProfile(cached);
} const { data, error } = await supabase
const { data } = await supabase
.from('profiles') .from('profiles')
.select('*') .select('*')
.eq('id', userId) .eq('id', id)
.single(); .single();
if (data) { if (data) {
setProfile(data); setProfile(data);
await cacheProfile(data); } else if (error && error.code !== 'PGRST116') {
console.error('Error fetching profile:', error);
} }
}; };
const signOut = async () => {
await supabase.auth.signOut();
};
useEffect(() => { useEffect(() => {
// One-shot bootstrap supabase.auth.getSession().then(({ data: { session } }) => {
supabase.auth.getSession().then(async ({ data }) => { setSession(session);
const currentUser = data.session?.user ?? null; setUser(session?.user ?? null);
setUser(currentUser); if (session?.user) {
if (currentUser) { refreshProfile(session.user.id).finally(() => setLoading(false));
await fetchProfile(currentUser.id); } else {
setLoading(false);
} }
setInitializing(false); // only ever called once - IonReactRouter mounts after this
}); });
// Subsequent events: update state, never touch initializing
const { const {
data: { subscription }, data: { subscription },
} = supabase.auth.onAuthStateChange(async (_event, session) => { } = supabase.auth.onAuthStateChange((_event, session) => {
const currentUser = session?.user ?? null; setSession(session);
setUser(currentUser); setUser(session?.user ?? null);
if (currentUser) { if (session?.user) {
await fetchProfile(currentUser.id); refreshProfile(session.user.id);
} else { } else {
setProfile(null); setProfile(null);
await clearCachedProfile();
} }
setLoading(false);
}); });
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, []); }, []);
if (initializing) { const signOut = async () => {
await supabase.auth.signOut();
};
if (loading) {
return ( return (
<div <div
style={{ style={{
@@ -111,7 +81,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
height: '100vh', height: '100vh',
backgroundColor: '#f5f7f4', background: 'var(--color-bg)',
}} }}
> >
<IonSpinner name="crescent" color="primary" /> <IonSpinner name="crescent" color="primary" />
@@ -121,17 +91,17 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{ user, session, profile, loading, refreshProfile, signOut }}
user,
profile,
refreshProfile: () =>
user ? fetchProfile(user.id) : Promise.resolve(),
signOut,
}}
> >
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
}; };
export const useAuth = () => useContext(AuthContext); export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
+552 -99
View File
@@ -14,117 +14,325 @@ export type Database = {
} }
public: { public: {
Tables: { Tables: {
event_categories: { check_ins: {
Row: { Row: {
accent_color: string | null checked_in_at: string | null
created_at: string expires_at: string
id: string id: string
name: string place_id: string
slug: string user_id: string
} }
Insert: { Insert: {
accent_color?: string | null checked_in_at?: string | null
created_at?: string expires_at: string
id?: string id?: string
name: string place_id: string
slug: string user_id: string
} }
Update: { Update: {
accent_color?: string | null checked_in_at?: string | null
created_at?: string expires_at?: string
id?: string id?: string
name?: string place_id?: string
slug?: string user_id?: 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: [ Relationships: [
{ {
foreignKeyName: "events_category_id_fkey" foreignKeyName: "check_ins_place_id_fkey"
columns: ["category_id"] columns: ["place_id"]
isOneToOne: false isOneToOne: false
referencedRelation: "event_categories" referencedRelation: "places"
referencedColumns: ["id"]
},
{
foreignKeyName: "check_ins_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
] ]
} }
news: { conversations: {
Row: { Row: {
content: string
created_at: string | null created_at: string | null
created_by: string
id: string id: string
image_url: string | null match_id: string
title: string
updated_at: string | null updated_at: string | null
} }
Insert: { Insert: {
content: string
created_at?: string | null created_at?: string | null
created_by: string
id?: string id?: string
image_url?: string | null match_id: string
title: string
updated_at?: string | null updated_at?: string | null
} }
Update: { Update: {
content?: string
created_at?: string | null created_at?: string | null
created_by?: string
id?: string id?: string
image_url?: string | null match_id?: string
title?: string
updated_at?: string | null updated_at?: string | null
} }
Relationships: [ Relationships: [
{ {
foreignKeyName: "news_created_by_fkey" foreignKeyName: "conversations_match_id_fkey"
columns: ["match_id"]
isOneToOne: true
referencedRelation: "matches"
referencedColumns: ["id"]
},
]
}
discovery_preferences: {
Row: {
goals: string[]
max_age: number
max_distance_km: number
min_age: number
updated_at: string | null
user_id: string
}
Insert: {
goals?: string[]
max_age?: number
max_distance_km?: number
min_age?: number
updated_at?: string | null
user_id: string
}
Update: {
goals?: string[]
max_age?: number
max_distance_km?: number
min_age?: number
updated_at?: string | null
user_id?: string
}
Relationships: [
{
foreignKeyName: "discovery_preferences_user_id_fkey"
columns: ["user_id"]
isOneToOne: true
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
matches: {
Row: {
created_at: string | null
id: string
last_message_at: string | null
user_a: string
user_b: string
}
Insert: {
created_at?: string | null
id?: string
last_message_at?: string | null
user_a: string
user_b: string
}
Update: {
created_at?: string | null
id?: string
last_message_at?: string | null
user_a?: string
user_b?: string
}
Relationships: [
{
foreignKeyName: "matches_user_a_fkey"
columns: ["user_a"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "matches_user_b_fkey"
columns: ["user_b"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
messages: {
Row: {
body: string
conversation_id: string
created_at: string | null
id: string
read_at: string | null
sender_id: string
}
Insert: {
body: string
conversation_id: string
created_at?: string | null
id?: string
read_at?: string | null
sender_id: string
}
Update: {
body?: string
conversation_id?: string
created_at?: string | null
id?: string
read_at?: string | null
sender_id?: string
}
Relationships: [
{
foreignKeyName: "messages_conversation_id_fkey"
columns: ["conversation_id"]
isOneToOne: false
referencedRelation: "conversations"
referencedColumns: ["id"]
},
{
foreignKeyName: "messages_sender_id_fkey"
columns: ["sender_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
notifications: {
Row: {
body: string
created_at: string | null
deep_link: string | null
id: string
read_at: string | null
title: string
type: string
user_id: string
}
Insert: {
body: string
created_at?: string | null
deep_link?: string | null
id?: string
read_at?: string | null
title: string
type: string
user_id: string
}
Update: {
body?: string
created_at?: string | null
deep_link?: string | null
id?: string
read_at?: string | null
title?: string
type?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "notifications_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
payments: {
Row: {
amount: number
created_at: string | null
currency: string
gateway: string
id: string
metadata: Json
reference: string
status: string
updated_at: string | null
user_id: string
}
Insert: {
amount: number
created_at?: string | null
currency?: string
gateway?: string
id?: string
metadata?: Json
reference: string
status?: string
updated_at?: string | null
user_id: string
}
Update: {
amount?: number
created_at?: string | null
currency?: string
gateway?: string
id?: string
metadata?: Json
reference?: string
status?: string
updated_at?: string | null
user_id?: string
}
Relationships: []
}
places: {
Row: {
address: string | null
busyness: string
category: string
created_at: string | null
created_by: string | null
description: string | null
google_place_id: string | null
id: string
image_path: string | null
latitude: number
longitude: number
name: string
rating: number
review_count: number
updated_at: string | null
}
Insert: {
address?: string | null
busyness?: string
category?: string
created_at?: string | null
created_by?: string | null
description?: string | null
google_place_id?: string | null
id?: string
image_path?: string | null
latitude: number
longitude: number
name: string
rating?: number
review_count?: number
updated_at?: string | null
}
Update: {
address?: string | null
busyness?: string
category?: string
created_at?: string | null
created_by?: string | null
description?: string | null
google_place_id?: string | null
id?: string
image_path?: string | null
latitude?: number
longitude?: number
name?: string
rating?: number
review_count?: number
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "places_created_by_fkey"
columns: ["created_by"] columns: ["created_by"]
isOneToOne: false isOneToOne: false
referencedRelation: "profiles" referencedRelation: "profiles"
@@ -132,39 +340,284 @@ export type Database = {
}, },
] ]
} }
profile_photos: {
Row: {
created_at: string | null
id: string
is_primary: boolean
position: number
storage_path: string
user_id: string
}
Insert: {
created_at?: string | null
id?: string
is_primary?: boolean
position?: number
storage_path: string
user_id: string
}
Update: {
created_at?: string | null
id?: string
is_primary?: boolean
position?: number
storage_path?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "profile_photos_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
profiles: { profiles: {
Row: { Row: {
avatar_url: string | null avatar_path: string | null
created_at: string bio: string | null
email: string birth_date: string | null
full_name: string | null city: string | null
created_at: string | null
fcm_token: string | null
full_name: string
gender: string | null
id: string id: string
ministry_group: string | null interests: string[]
role: string latitude: number | null
updated_at: string longitude: number | null
onboarding_complete: boolean
relationship_goal: string | null
updated_at: string | null
} }
Insert: { Insert: {
avatar_url?: string | null avatar_path?: string | null
created_at?: string bio?: string | null
email: string birth_date?: string | null
full_name?: string | null city?: string | null
created_at?: string | null
fcm_token?: string | null
full_name?: string
gender?: string | null
id: string id: string
ministry_group?: string | null interests?: string[]
role?: string latitude?: number | null
updated_at?: string longitude?: number | null
onboarding_complete?: boolean
relationship_goal?: string | null
updated_at?: string | null
} }
Update: { Update: {
avatar_url?: string | null avatar_path?: string | null
created_at?: string bio?: string | null
email?: string birth_date?: string | null
full_name?: string | null city?: string | null
created_at?: string | null
fcm_token?: string | null
full_name?: string
gender?: string | null
id?: string id?: string
ministry_group?: string | null interests?: string[]
role?: string latitude?: number | null
updated_at?: string longitude?: number | null
onboarding_complete?: boolean
relationship_goal?: string | null
updated_at?: string | null
} }
Relationships: [] Relationships: []
} }
reel_comments: {
Row: {
body: string
created_at: string | null
id: string
reel_id: string
user_id: string
}
Insert: {
body: string
created_at?: string | null
id?: string
reel_id: string
user_id: string
}
Update: {
body?: string
created_at?: string | null
id?: string
reel_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "reel_comments_reel_id_fkey"
columns: ["reel_id"]
isOneToOne: false
referencedRelation: "reels"
referencedColumns: ["id"]
},
{
foreignKeyName: "reel_comments_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
reel_likes: {
Row: {
created_at: string | null
reel_id: string
user_id: string
}
Insert: {
created_at?: string | null
reel_id: string
user_id: string
}
Update: {
created_at?: string | null
reel_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "reel_likes_reel_id_fkey"
columns: ["reel_id"]
isOneToOne: false
referencedRelation: "reels"
referencedColumns: ["id"]
},
{
foreignKeyName: "reel_likes_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
reels: {
Row: {
author_id: string
caption: string
comments_count: number
created_at: string | null
id: string
likes_count: number
media_path: string
media_type: string
place_id: string
shares_count: number
thumbnail_path: string | null
}
Insert: {
author_id: string
caption?: string
comments_count?: number
created_at?: string | null
id?: string
likes_count?: number
media_path: string
media_type: string
place_id: string
shares_count?: number
thumbnail_path?: string | null
}
Update: {
author_id?: string
caption?: string
comments_count?: number
created_at?: string | null
id?: string
likes_count?: number
media_path?: string
media_type?: string
place_id?: string
shares_count?: number
thumbnail_path?: string | null
}
Relationships: [
{
foreignKeyName: "reels_author_id_fkey"
columns: ["author_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "reels_place_id_fkey"
columns: ["place_id"]
isOneToOne: false
referencedRelation: "places"
referencedColumns: ["id"]
},
]
}
subscriptions: {
Row: {
active_until: string | null
tier: string
updated_at: string | null
user_id: string
}
Insert: {
active_until?: string | null
tier?: string
updated_at?: string | null
user_id: string
}
Update: {
active_until?: string | null
tier?: string
updated_at?: string | null
user_id?: string
}
Relationships: []
}
swipes: {
Row: {
action: string
created_at: string | null
id: string
swiped_id: string
swiper_id: string
}
Insert: {
action: string
created_at?: string | null
id?: string
swiped_id: string
swiper_id: string
}
Update: {
action?: string
created_at?: string | null
id?: string
swiped_id?: string
swiper_id?: string
}
Relationships: [
{
foreignKeyName: "swipes_swiped_id_fkey"
columns: ["swiped_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "swipes_swiper_id_fkey"
columns: ["swiper_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
} }
Views: { Views: {
[_ in never]: never [_ in never]: never
-685
View File
@@ -1,685 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonSpinner,
IonSelect,
IonSelectOption,
IonDatetime,
IonModal,
IonItem,
IonLabel,
} from '@ionic/react';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import {
chevronBackOutline,
calendarOutline,
timeOutline,
} from 'ionicons/icons';
import { EventCategory, ChurchEvent } from '../types';
const AdminEventForm: React.FC = () => {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [categoryId, setCategoryId] = useState('');
const [audience, setAudience] = useState<'all' | 'youth' | 'children'>('all');
const [venue, setVenue] = useState('');
// Date states
const [startAt, setStartAt] = useState<string>(new Date().toISOString());
const [endAt, setEndAt] = useState<string>('');
const [isFeatured, setIsFeatured] = useState(false);
const [categories, setCategories] = useState<EventCategory[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const { id } = useParams<{ id: string }>();
const isEditMode = !!id;
const history = useHistory();
const { user, profile } = useAuth();
useEffect(() => {
if (profile && profile.role !== 'admin') {
history.replace('/home');
return;
}
const loadData = async () => {
setLoading(true);
// Load categories
const { data: catData, error: catError } = await supabase
.from('event_categories')
.select('*')
.order('name');
if (catError) {
setError(catError.message);
setLoading(false);
return;
}
setCategories(catData as EventCategory[]);
// If edit mode, load event
if (isEditMode) {
const { data: eventData, error: eventError } = await supabase
.from('events')
.select('*')
.eq('id', id)
.single();
if (eventError) {
setError(eventError.message);
} else {
const event = eventData as ChurchEvent;
setTitle(event.title);
setDescription(event.description);
setCategoryId(event.category_id);
setAudience(event.audience);
setVenue(event.venue);
setStartAt(event.start_at);
setEndAt(event.end_at || '');
setIsFeatured(event.is_featured);
}
} else if (catData && catData.length > 0) {
setCategoryId(catData[0].id); // default category
}
setLoading(false);
};
if (profile?.role === 'admin') {
loadData();
}
}, [id, isEditMode, profile, history]);
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const getDayLabel = (dateString: string) => {
const d = new Date(dateString);
return d.toLocaleDateString(undefined, { weekday: 'long' });
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!title || !description || !categoryId || !venue || !startAt) {
showError('Please fill out all required fields');
return;
}
if (!user || profile?.role !== 'admin') {
showError('Unauthorized');
return;
}
setSaving(true);
setError(null);
const dayLabel = getDayLabel(startAt);
const eventPayload = {
title,
description,
category_id: categoryId,
audience,
venue,
start_at: startAt,
end_at: endAt || null,
day_label: dayLabel,
is_featured: isFeatured,
};
if (isEditMode) {
const { error: updateError } = await supabase
.from('events')
.update(eventPayload)
.eq('id', id);
if (updateError) {
showError(updateError.message);
setSaving(false);
return;
}
} else {
const { error: insertError } = await supabase.from('events').insert({
...eventPayload,
created_by: user.id,
});
if (insertError) {
showError(insertError.message);
setSaving(false);
return;
}
}
setSaving(false);
history.goBack();
};
const handleDelete = async () => {
if (!isEditMode) return;
const confirmDelete = window.confirm(
'Are you sure you want to delete this event?'
);
if (!confirmDelete) return;
setSaving(true);
const { error: deleteError } = await supabase
.from('events')
.delete()
.eq('id', id);
if (deleteError) {
showError(deleteError.message);
setSaving(false);
return;
}
history.replace('/events');
};
return (
<IonPage style={{ background: '#f5f7f4' }}>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonButtons slot="start">
<IonButton onClick={() => history.goBack()} disabled={saving}>
<IonIcon
icon={chevronBackOutline}
style={{ color: '#1f2937' }}
slot="icon-only"
/>
</IonButton>
</IonButtons>
<IonTitle
style={{ fontSize: '18px', fontWeight: '700', color: '#1f2937' }}
>
{isEditMode ? 'Edit Event' : 'New Event'}
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent
style={{
'--background': '#f5f7f4',
'--padding-start': '20px',
'--padding-end': '20px',
'--padding-top': '16px',
'--padding-bottom': '24px',
}}
>
{loading ? (
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '40px 0',
}}
>
<IonSpinner name="crescent" color="primary" />
</div>
) : (
<>
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '20px',
margin: '0 0 16px 0',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<h2
style={{
margin: 0,
fontSize: '18px',
fontWeight: '700',
color: '#1f2937',
}}
>
Event Details
</h2>
<p
style={{
margin: '4px 0 0 0',
fontSize: '14px',
fontWeight: '400',
color: '#6b7280',
}}
>
Provide the schedule and venue info for members.
</p>
</div>
{error && (
<div
style={{
background: 'rgba(163, 59, 50, 0.10)',
borderRadius: '12px',
padding: '12px 14px',
marginBottom: '16px',
}}
>
<p
style={{
margin: 0,
fontSize: '13px',
fontWeight: '500',
color: '#a33b32',
lineHeight: '1.4',
}}
>
{error}
</p>
</div>
)}
<form
onSubmit={handleSave}
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '24px',
margin: '0 0 16px 0',
display: 'flex',
flexDirection: 'column',
gap: '16px',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
{/* Title */}
<div>
<label
style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '600',
color: '#1f2937',
}}
>
Event Title
</label>
<input
type="text"
placeholder="e.g. Sunday Service"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
style={{
width: '100%',
background: '#eef2ec',
border: 'none',
borderRadius: '12px',
padding: '0 14px',
height: '48px',
fontSize: '15px',
color: '#1f2937',
outline: 'none',
boxSizing: 'border-box',
}}
/>
</div>
{/* Category */}
<div>
<label
style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '600',
color: '#1f2937',
}}
>
Category
</label>
<div
style={{
background: '#eef2ec',
borderRadius: '12px',
padding: '0 14px',
height: '48px',
display: 'flex',
alignItems: 'center',
}}
>
<IonSelect
value={categoryId}
onIonChange={(e) => setCategoryId(e.detail.value)}
interface="popover"
style={{
width: '100%',
color: '#1f2937',
fontSize: '15px',
}}
>
{categories.map((cat) => (
<IonSelectOption key={cat.id} value={cat.id}>
{cat.name}
</IonSelectOption>
))}
</IonSelect>
</div>
</div>
{/* Audience */}
<div>
<label
style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '600',
color: '#1f2937',
}}
>
Audience
</label>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="button"
onClick={() => setAudience('all')}
style={{
flex: 1,
background: audience === 'all' ? '#2f6b4f' : '#eef2ec',
color: audience === 'all' ? '#ffffff' : '#4b5563',
border: 'none',
borderRadius: '999px',
padding: '12px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
}}
>
All Members
</button>
<button
type="button"
onClick={() => setAudience('youth')}
style={{
flex: 1,
background: audience === 'youth' ? '#2f6b4f' : '#eef2ec',
color: audience === 'youth' ? '#ffffff' : '#4b5563',
border: 'none',
borderRadius: '999px',
padding: '12px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
}}
>
Youth Only
</button>
<button
type="button"
onClick={() => setAudience('children')}
style={{
flex: 1,
background:
audience === 'children' ? '#2f6b4f' : '#eef2ec',
color: audience === 'children' ? '#ffffff' : '#4b5563',
border: 'none',
borderRadius: '999px',
padding: '12px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
}}
>
Children
</button>
</div>
</div>
{/* Venue */}
<div>
<label
style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '600',
color: '#1f2937',
}}
>
Venue
</label>
<input
type="text"
placeholder="e.g. Main Auditorium"
value={venue}
onChange={(e) => setVenue(e.target.value)}
required
style={{
width: '100%',
background: '#eef2ec',
border: 'none',
borderRadius: '12px',
padding: '0 14px',
height: '48px',
fontSize: '15px',
color: '#1f2937',
outline: 'none',
boxSizing: 'border-box',
}}
/>
</div>
{/* Dates */}
<div>
<label
style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '600',
color: '#1f2937',
}}
>
Start Time
</label>
<div
style={{
background: '#eef2ec',
borderRadius: '12px',
padding: '4px 14px',
display: 'flex',
alignItems: 'center',
}}
>
<IonDatetime
presentation="date-time"
value={startAt}
onIonChange={(e) => setStartAt(e.detail.value as string)}
style={{ background: 'transparent' }}
/>
</div>
</div>
<div>
<label
style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '600',
color: '#1f2937',
}}
>
End Time (Optional)
</label>
<div
style={{
background: '#eef2ec',
borderRadius: '12px',
padding: '4px 14px',
display: 'flex',
alignItems: 'center',
}}
>
<IonDatetime
presentation="date-time"
value={endAt}
onIonChange={(e) =>
setEndAt((e.detail.value as string) || '')
}
showClearButton={true}
style={{ background: 'transparent' }}
/>
</div>
</div>
{/* Description */}
<div>
<label
style={{
display: 'block',
marginBottom: '6px',
fontSize: '14px',
fontWeight: '600',
color: '#1f2937',
}}
>
Description
</label>
<textarea
placeholder="Describe the event..."
value={description}
onChange={(e) => setDescription(e.target.value)}
required
style={{
width: '100%',
background: '#eef2ec',
border: 'none',
borderRadius: '12px',
padding: '14px',
minHeight: '120px',
fontSize: '15px',
color: '#1f2937',
outline: 'none',
resize: 'vertical',
boxSizing: 'border-box',
}}
/>
</div>
{/* Featured Toggle */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 0',
}}
>
<span
style={{
fontSize: '14px',
fontWeight: '600',
color: '#1f2937',
}}
>
Feature on Home Screen
</span>
<button
type="button"
onClick={() => setIsFeatured(!isFeatured)}
style={{
width: '44px',
height: '24px',
background: isFeatured ? '#2f6b4f' : '#eef2ec',
borderRadius: '12px',
border: 'none',
position: 'relative',
cursor: 'pointer',
transition: '0.2s',
}}
>
<div
style={{
width: '20px',
height: '20px',
background: '#ffffff',
borderRadius: '50%',
position: 'absolute',
top: '2px',
left: isFeatured ? '22px' : '2px',
transition: '0.2s',
}}
/>
</button>
</div>
<button
type="submit"
disabled={saving}
style={{
background: '#2f6b4f',
border: 'none',
borderRadius: '999px',
padding: '16px',
fontSize: '15px',
fontWeight: '600',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: '8px',
}}
>
{saving ? (
<IonSpinner name="dots" color="light" />
) : isEditMode ? (
'Update Event'
) : (
'Create Event'
)}
</button>
{isEditMode && (
<button
type="button"
onClick={handleDelete}
disabled={saving}
style={{
background: 'rgba(163, 59, 50, 0.10)',
border: 'none',
borderRadius: '999px',
padding: '16px',
fontSize: '15px',
fontWeight: '600',
color: '#a33b32',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Delete Event
</button>
)}
</form>
</>
)}
</IonContent>
</IonPage>
);
};
export default AdminEventForm;
-330
View File
@@ -1,330 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonSpinner,
IonItem,
IonLabel,
IonInput,
IonTextarea,
} from '@ionic/react';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { chevronBackOutline, saveOutline, trashOutline } from 'ionicons/icons';
const AdminNewsForm: React.FC = () => {
const history = useHistory();
const { id } = useParams<{ id: string }>();
const isEdit = !!id && id !== 'new';
const { user, profile } = useAuth();
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(isEdit);
const [error, setError] = useState<string | null>(null);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [imageUrl, setImageUrl] = useState('');
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
useEffect(() => {
if (profile?.role !== 'admin') {
history.replace('/profile');
return;
}
if (isEdit) {
loadNewsItem();
}
}, [profile, isEdit]);
const loadNewsItem = async () => {
try {
const { data, error } = await supabase
.from('news')
.select('*')
.eq('id', id)
.single();
if (error) throw error;
if (data) {
setTitle(data.title);
setContent(data.content);
setImageUrl(data.image_url || '');
}
} catch (err: any) {
showError(err.message);
} finally {
setInitialLoading(false);
}
};
const handleSave = async () => {
if (!title.trim() || !content.trim()) {
showError('Title and content are required.');
return;
}
setLoading(true);
try {
if (isEdit) {
const { error } = await supabase
.from('news')
.update({
title,
content,
image_url: imageUrl.trim() || null,
})
.eq('id', id);
if (error) throw error;
} else {
const { error } = await supabase.from('news').insert({
title,
content,
image_url: imageUrl.trim() || null,
created_by: user!.id,
});
if (error) throw error;
}
history.goBack();
} catch (err: any) {
showError(err.message);
setLoading(false);
}
};
const handleDelete = async () => {
if (!window.confirm('Delete this news item?')) return;
setLoading(true);
try {
const { error } = await supabase.from('news').delete().eq('id', id);
if (error) throw error;
history.goBack();
} catch (err: any) {
showError(err.message);
setLoading(false);
}
};
if (initialLoading) {
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonButtons slot="start">
<IonButton onClick={() => history.goBack()}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent style={{ '--background': '#f5f7f4' }}>
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: '40px',
}}
>
<IonSpinner name="crescent" />
</div>
</IonContent>
</IonPage>
);
}
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonButtons slot="start">
<IonButton onClick={() => history.goBack()}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle style={{ fontWeight: '700', fontSize: '18px' }}>
{isEdit ? 'Edit News' : 'Create News'}
</IonTitle>
{isEdit && (
<IonButtons slot="end">
<IonButton onClick={handleDelete} color="danger">
<IonIcon icon={trashOutline} slot="icon-only" />
</IonButton>
</IonButtons>
)}
</IonToolbar>
</IonHeader>
<IonContent style={{ '--background': '#f5f7f4' }}>
<div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
{error && (
<div
style={{
background: '#fee2e2',
color: '#991b1b',
padding: '12px',
borderRadius: '8px',
fontSize: '14px',
marginBottom: '20px',
}}
>
{error}
</div>
)}
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '20px',
display: 'flex',
flexDirection: 'column',
gap: '16px',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<div>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: '600',
color: '#4b5563',
marginBottom: '8px',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Title
</label>
<IonItem
lines="none"
style={{
'--background': '#f9faf9',
'--border-radius': '12px',
'--padding-start': '16px',
}}
>
<IonInput
value={title}
onIonChange={(e) => setTitle(e.detail.value!)}
placeholder="Enter news title"
style={{ fontWeight: '500' }}
/>
</IonItem>
</div>
<div>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: '600',
color: '#4b5563',
marginBottom: '8px',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Image URL (Optional)
</label>
<IonItem
lines="none"
style={{
'--background': '#f9faf9',
'--border-radius': '12px',
'--padding-start': '16px',
}}
>
<IonInput
value={imageUrl}
onIonChange={(e) => setImageUrl(e.detail.value!)}
placeholder="https://example.com/image.jpg"
/>
</IonItem>
</div>
<div>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: '600',
color: '#4b5563',
marginBottom: '8px',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Content
</label>
<IonItem
lines="none"
style={{
'--background': '#f9faf9',
'--border-radius': '12px',
'--padding-start': '16px',
alignItems: 'flex-start',
}}
>
<IonTextarea
value={content}
onIonChange={(e) => setContent(e.detail.value!)}
placeholder="Write your news update here..."
autoGrow
style={{ minHeight: '150px' }}
/>
</IonItem>
</div>
</div>
<button
onClick={handleSave}
disabled={loading}
style={{
width: '100%',
background: '#2f6b4f',
color: '#ffffff',
border: 'none',
borderRadius: '999px',
padding: '16px',
fontSize: '16px',
fontWeight: '600',
marginTop: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.7 : 1,
}}
>
{loading ? (
<IonSpinner
name="crescent"
style={{ width: '24px', height: '24px' }}
/>
) : (
<>
<IonIcon icon={saveOutline} />
Save News
</>
)}
</button>
</div>
</IonContent>
</IonPage>
);
};
export default AdminNewsForm;
-323
View File
@@ -1,323 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
const Auth: React.FC = () => {
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const history = useHistory();
const { user, profile } = useAuth();
useIonViewWillEnter(() => {
setActiveTab('login');
setError(null);
});
const loadProfileAndNavigate = () => {
if (!user) return;
if (profile !== undefined && profile === null) {
history.replace('/setup-profile');
} else if (profile !== undefined && profile !== null) {
history.replace('/home');
}
};
useEffect(() => {
if (user) {
loadProfileAndNavigate();
}
}, [user, profile]);
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
if (error.message.toLowerCase().includes('email not confirmed')) {
await supabase.auth.resend({ type: 'signup', email });
history.push('/verify-email', { email, resent: true });
setLoading(false);
return;
} else {
showError(error.message);
}
setLoading(false);
return;
}
// Success: keep loading=true — useEffect above navigates when user arrives in context
};
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
showError('Passwords do not match');
return;
}
setLoading(true);
setError(null);
const { error } = await supabase.auth.signUp({ email, password });
if (error) {
showError(error.message);
setLoading(false);
return;
}
history.push('/verify-email', { email });
setLoading(false);
};
const handleTabChange = (tab: 'login' | 'register') => {
setActiveTab(tab);
setError(null);
if (tab === 'login') {
setConfirmPassword('');
}
};
return (
<IonPage style={{ background: '#f6f1e8' }}>
<IonContent
style={{
'--background': 'linear-gradient(180deg, #f9f5ee 0%, #f3ede3 100%)',
'--padding-start': '0px',
'--padding-end': '0px',
'--padding-top': '0px',
'--padding-bottom': '0px',
}}
>
<div
style={{
padding:
'calc(var(--ion-safe-area-top, 0px) + 18px) 20px calc(var(--ion-safe-area-bottom, 0px) + 28px)',
minHeight: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Welcome header block */}
<div
style={{
background: 'transparent',
borderRadius: '0px',
padding: '0px',
margin: '0 0 24px 0',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<h1
style={{
margin: 0,
fontSize: '24px',
fontWeight: '700',
color: '#1f2937',
lineHeight: '1.2',
}}
>
EAGLES ASSEMBLY
</h1>
<p
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: '#6b7280',
lineHeight: '1.5',
}}
>
Welcome back to our community app
</p>
</div>
{/* Auth mode pill switcher */}
<div
style={{
background: '#ffffff',
borderRadius: '999px',
padding: '4px',
margin: '0 0 20px 0',
display: 'flex',
gap: '4px',
}}
>
<button
onClick={() => handleTabChange('login')}
style={{
flex: 1,
background: activeTab === 'login' ? '#2f6b4f' : 'transparent',
border: 'none',
borderRadius: '999px',
padding: '12px 18px',
fontSize: '14px',
fontWeight: '600',
color: activeTab === 'login' ? '#ffffff' : '#6b7280',
cursor: 'pointer',
}}
>
Login
</button>
<button
onClick={() => handleTabChange('register')}
style={{
flex: 1,
background:
activeTab === 'register' ? '#2f6b4f' : 'transparent',
border: 'none',
borderRadius: '999px',
padding: '12px 18px',
fontSize: '14px',
fontWeight: '600',
color: activeTab === 'register' ? '#ffffff' : '#6b7280',
cursor: 'pointer',
}}
>
Register
</button>
</div>
{/* Form card */}
<form
onSubmit={activeTab === 'login' ? handleSignIn : handleSignUp}
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '24px',
margin: '0 0 16px 0',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<input
type="email"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{
background: '#eef2ec',
border: 'none',
borderRadius: '12px',
padding: '0 14px',
height: '48px',
fontSize: '15px',
color: '#1f2937',
outline: 'none',
}}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{
background: '#eef2ec',
border: 'none',
borderRadius: '12px',
padding: '0 14px',
height: '48px',
fontSize: '15px',
color: '#1f2937',
outline: 'none',
}}
/>
{activeTab === 'register' && (
<input
type="password"
placeholder="Confirm password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
style={{
background: '#eef2ec',
border: 'none',
borderRadius: '12px',
padding: '0 14px',
height: '48px',
fontSize: '15px',
color: '#1f2937',
outline: 'none',
}}
/>
)}
<button
type="submit"
disabled={loading}
style={{
background: '#2f6b4f',
border: 'none',
borderRadius: '999px',
padding: '16px',
fontSize: '15px',
fontWeight: '600',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: '8px',
}}
>
{loading ? (
<IonSpinner name="dots" color="light" />
) : activeTab === 'login' ? (
'Sign In'
) : (
'Create Account'
)}
</button>
</form>
{/* Error area */}
{error && (
<div
style={{
background: 'rgba(190, 60, 60, 0.10)',
borderRadius: '12px',
padding: '12px 14px',
margin: '0',
display: 'flex',
}}
>
<p
style={{
margin: 0,
fontSize: '13px',
fontWeight: '500',
color: '#a33b32',
lineHeight: '1.4',
}}
>
{error}
</p>
</div>
)}
</div>
</IonContent>
</IonPage>
);
};
export default Auth;
+425
View File
@@ -0,0 +1,425 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonInput,
IonItem,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonRouterLink,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { mail, lockClosed, logoFacebook } from 'ionicons/icons';
import { IonIcon } from '@ionic/react';
import { enablePreviewMode } from '../utils/previewMode';
import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { Capacitor } from '@capacitor/core';
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
const AuthPage: React.FC = () => {
const history = useHistory();
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleAuth = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
if (mode === 'signin') {
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, type: 'signup' });
return;
}
throw error;
}
history.push('/icebreaker');
} else {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: { onboarding_complete: false },
},
});
if (error) throw error;
if (data.session) {
history.push('/onboarding/profile');
} else {
history.push('/verify-email', { email, type: 'signup' });
}
}
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
const enterPreviewMode = () => {
enablePreviewMode();
history.push('/icebreaker');
};
const handleSocialSignIn = async (provider: 'google' | 'apple') => {
if (!Capacitor.isNativePlatform()) {
showError(
`${provider === 'google' ? 'Google' : 'Apple'} Sign-In is only available on native devices.`
);
return;
}
setLoading(true);
try {
let result;
if (provider === 'google') {
result = await FirebaseAuthentication.signInWithGoogle();
} else {
result = await FirebaseAuthentication.signInWithApple({
skipNativeAuth: true,
});
}
const { error } = await supabase.auth.signInWithIdToken({
provider,
token: result.credential!.idToken!,
nonce: result.credential?.nonce,
});
if (error) throw error;
history.push('/icebreaker');
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
className="ion-padding"
style={{
'--background': 'var(--color-bg)',
display: 'flex',
flexDirection: 'column',
paddingTop: 'calc(var(--ion-safe-area-top) + 40px)',
}}
>
<div style={{ padding: '0 20px' }}>
<div style={{ marginBottom: '44px', paddingTop: '24px' }}>
<h1
style={{
fontSize: '28px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 42px',
}}
>
{mode === 'signin' ? 'Hi there, 👋' : 'Create account'}
</h1>
<h2
style={{
fontSize: '24px',
fontWeight: 700,
color: 'var(--color-text-primary)',
margin: '0 0 12px',
}}
>
{mode === 'signin'
? 'Sign in to continue'
: 'Sign up to continue'}
</h2>
<p
style={{
fontSize: '16px',
color: 'var(--color-text-secondary)',
margin: 0,
lineHeight: 1.35,
}}
>
{mode === 'signin'
? "Don't have an account? Enter your email and password or choose one of the options below."
: 'Enter your email and password to start discovering people and places nearby.'}
</p>
</div>
<div
style={{
display: 'flex',
background: 'var(--color-surface-raised)',
padding: '4px',
borderRadius: 'var(--radius-pill)',
marginBottom: '20px',
}}
>
{(['signin', 'signup'] as const).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
style={{
flex: 1,
padding: '10px',
borderRadius: 'var(--radius-pill)',
border: 'none',
fontSize: '14px',
fontWeight: 700,
transition: '0.2s',
background:
mode === m ? 'var(--ion-color-primary)' : 'transparent',
color: mode === m ? '#ffffff' : 'var(--color-text-primary)',
}}
>
{m === 'signin' ? 'Sign In' : 'Sign Up'}
</button>
))}
</div>
<form
onSubmit={handleAuth}
style={{
background: 'transparent',
padding: '0',
borderRadius: '0',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-input)',
'--border-radius': 'var(--radius-control)',
'--border-width': '1px',
'--border-style': 'solid',
'--border-color': 'var(--color-text-secondary)',
'--min-height': '48px',
}}
>
<IonIcon
icon={mail}
slot="start"
style={{ color: 'var(--ion-color-primary)', fontSize: '20px' }}
/>
<IonInput
label="Email"
labelPlacement="floating"
type="email"
value={email}
onIonInput={(e) => setEmail(e.detail.value!)}
required
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-input)',
'--border-radius': 'var(--radius-control)',
'--border-width': '1px',
'--border-style': 'solid',
'--border-color': 'var(--color-text-secondary)',
'--min-height': '48px',
}}
>
<IonIcon
icon={lockClosed}
slot="start"
style={{ color: 'var(--ion-color-primary)', fontSize: '20px' }}
/>
<IonInput
label="Password"
labelPlacement="floating"
type="password"
value={password}
onIonInput={(e) => setPassword(e.detail.value!)}
required
/>
</IonItem>
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
}}
>
{error}
</div>
)}
<IonButton
type="submit"
expand="block"
disabled={loading}
style={{
'--border-radius': 'var(--radius-pill)',
'--color': '#000000',
fontWeight: 800,
height: '48px',
margin: '8px 28px 0',
}}
>
{loading ? (
<IonSpinner name="crescent" />
) : mode === 'signin' ? (
'Sign in'
) : (
'Continue'
)}
</IonButton>
{mode === 'signin' && (
<IonRouterLink
routerLink="/forgot-password"
style={{
textAlign: 'center',
fontSize: '13px',
color: 'var(--ion-color-primary)',
marginTop: '8px',
}}
>
Forgot password?
</IonRouterLink>
)}
</form>
<div
style={{
textAlign: 'center',
margin: '56px 0 26px',
fontSize: '13px',
color: 'var(--color-text-tertiary)',
}}
>
OR
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
padding: '0 28px',
}}
>
<IonButton
expand="block"
onClick={() => handleSocialSignIn('apple')}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
style={{ marginRight: '10px' }}
>
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
Sign in with Apple
</IonButton>
<IonButton
expand="block"
onClick={() =>
showError(
'Facebook Sign-In is only available on native devices.'
)
}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<IonIcon
icon={logoFacebook}
style={{ marginRight: '10px', fontSize: '22px' }}
/>
Sign in with Facebook
</IonButton>
<IonButton
expand="block"
onClick={() => handleSocialSignIn('google')}
style={{
flex: 1,
'--background': '#ffffff',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
<span
style={{
fontSize: '24px',
fontWeight: 900,
marginRight: '12px',
}}
>
G
</span>
Sign in with Google
</IonButton>
<IonButton
expand="block"
onClick={enterPreviewMode}
style={{
flex: 1,
'--background': 'var(--ion-color-primary)',
'--color': '#000000',
'--border-radius': 'var(--radius-pill)',
'--box-shadow': 'none',
height: '48px',
fontWeight: 800,
}}
>
Preview with dummy data
</IonButton>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default AuthPage;
+311
View File
@@ -0,0 +1,311 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonIcon,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonItem,
IonTextarea,
IonSelect,
IonSelectOption,
} from '@ionic/react';
import {
closeOutline,
cameraOutline,
imageOutline,
sendOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const CameraPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [caption, setCaption] = useState('');
const [placeId, setPlaceId] = useState<string | null>(null);
const [places, setPlaces] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [isPublishOpen, setIsPublishOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchPlaces();
});
const fetchPlaces = async () => {
const { data } = await supabase.from('places').select('id, name');
setPlaces(data || []);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewUrl(URL.createObjectURL(selectedFile));
setIsPublishOpen(true);
}
};
const handlePublish = async () => {
if (!file || !user || !placeId) {
setError('Please select a place and add a file.');
return;
}
setUploading(true);
try {
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
const filePath = `${fileName}`;
// 1. Upload to storage
const { error: uploadError } = await supabase.storage
.from('reels')
.upload(filePath, file);
if (uploadError) throw uploadError;
// 2. Create reel record
const { error: reelError } = await supabase.from('reels').insert({
author_id: user.id,
place_id: placeId,
caption,
media_path: filePath,
media_type: file.type.startsWith('video') ? 'video' : 'image',
});
if (reelError) throw reelError;
history.replace(`/reels/${placeId}`);
} catch (err: any) {
setError(err.message);
} finally {
setUploading(false);
}
};
return (
<IonPage>
<IonContent fullscreen style={{ '--background': '#000000' }}>
<div
style={{
position: 'absolute',
top: 'calc(14px + var(--ion-safe-area-top))',
left: '20px',
zIndex: 10,
}}
>
<IonButton
fill="clear"
onClick={() => history.goBack()}
style={{
'--background': 'rgba(0,0,0,0.3)',
'--color': '#ffffff',
'--border-radius': '50%',
width: '40px',
height: '40px',
margin: 0,
}}
>
<IonIcon icon={closeOutline} slot="icon-only" />
</IonButton>
</div>
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
}}
>
<div
style={{
width: '120px',
height: '120px',
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '24px',
}}
>
<IonIcon
icon={cameraOutline}
style={{ fontSize: '48px', opacity: 0.5 }}
/>
</div>
<h2
style={{ fontSize: '20px', fontWeight: 800, marginBottom: '8px' }}
>
Create a Reel
</h2>
<p
style={{
opacity: 0.6,
fontSize: '14px',
marginBottom: '32px',
textAlign: 'center',
padding: '0 40px',
}}
>
Share your experience at a local spot. Select a photo or video to
begin.
</p>
<label
style={{
background: 'var(--ion-color-primary)',
color: '#ffffff',
padding: '14px 32px',
borderRadius: 'var(--radius-pill)',
fontWeight: 700,
fontSize: '15px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<IonIcon icon={imageOutline} style={{ fontSize: '20px' }} />
Select Media
<input
type="file"
accept="image/*,video/*"
onChange={handleFileChange}
hidden
/>
</label>
</div>
{/* Publish Modal */}
<IonModal
isOpen={isPublishOpen}
onDidDismiss={() => {
setIsPublishOpen(false);
setPreviewUrl(null);
setFile(null);
}}
>
<IonHeader>
<IonToolbar>
<IonTitle>New Reel</IonTitle>
<IonButtons slot="start">
<IonButton onClick={() => setIsPublishOpen(false)}>
Cancel
</IonButton>
</IonButtons>
<IonButtons slot="end">
<IonButton
onClick={handlePublish}
disabled={uploading || !placeId}
>
{uploading ? <IonSpinner name="crescent" /> : 'Share'}
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
<div
style={{
width: '100px',
height: '140px',
borderRadius: '12px',
overflow: 'hidden',
background: '#000',
flexShrink: 0,
}}
>
{file?.type.startsWith('video') ? (
<video
src={previewUrl || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<img
src={previewUrl || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
alt="Preview"
/>
)}
</div>
<IonTextarea
placeholder="Write a caption..."
value={caption}
onIonInput={(e) => setCaption(e.detail.value!)}
rows={6}
style={{
'--background': 'var(--color-bg)',
'--padding-start': '12px',
'--border-radius': '12px',
}}
/>
</div>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Tag a place"
labelPlacement="floating"
placeholder="Select place"
value={placeId}
onIonChange={(e) => setPlaceId(e.detail.value)}
>
{places.map((p) => (
<IonSelectOption key={p.id} value={p.id}>
{p.name}
</IonSelectOption>
))}
</IonSelect>
</IonItem>
{error && (
<div
style={{
color: 'var(--ion-color-danger)',
fontSize: '13px',
marginTop: '12px',
padding: '0 4px',
}}
>
{error}
</div>
)}
</IonContent>
</IonModal>
</IonContent>
</IonPage>
);
};
export default CameraPage;
+263
View File
@@ -0,0 +1,263 @@
import React, { useState, useEffect, useRef } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonInput,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { chevronBackOutline, sendOutline } from 'ionicons/icons';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import Avatar from '../components/Avatar';
import { previewChats, previewMessages } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const ChatThreadPage: React.FC = () => {
const history = useHistory();
const { conversationId } = useParams<{ conversationId: string }>();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [messages, setMessages] = useState<any[]>([]);
const [otherUser, setOtherUser] = useState<any>(null);
const [text, setText] = useState('');
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLIonContentElement>(null);
const fetchThread = async () => {
setLoading(true);
if (previewMode) {
const chat =
previewChats.find((item) => item.id === conversationId) ||
previewChats[0];
setOtherUser(chat.otherUser);
setMessages(previewMessages[chat.id] || []);
setLoading(false);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
return;
}
if (!user) return;
try {
// 1. Get other participant
const { data: conv } = await supabase
.from('conversations')
.select(
`
match_id,
matches(
user_a:profiles!matches_user_a_fkey(id, full_name, avatar_path),
user_b:profiles!matches_user_b_fkey(id, full_name, avatar_path)
)
`
)
.eq('id', conversationId)
.single();
if (conv) {
const match = (conv as any).matches;
setOtherUser(match.user_a.id === user.id ? match.user_b : match.user_a);
}
// 2. Get messages
const { data: msgs } = await supabase
.from('messages')
.select('*')
.eq('conversation_id', conversationId)
.order('created_at', { ascending: true });
setMessages(msgs || []);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
} catch (err: any) {
console.error('Fetch thread error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchThread();
});
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim() || (!user && !previewMode) || sending) return;
setSending(true);
const msgBody = text.trim();
setText('');
if (previewMode) {
const previewMessage = {
id: `preview-message-${Date.now()}`,
sender_id: 'preview-me',
body: msgBody,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, previewMessage]);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
setSending(false);
return;
}
try {
const { data, error } = await supabase
.from('messages')
.insert({
conversation_id: conversationId,
sender_id: user.id,
body: msgBody,
})
.select()
.single();
if (error) throw error;
setMessages((prev) => [...prev, data]);
setTimeout(() => scrollRef.current?.scrollToBottom(300), 100);
// Invoke notification
supabase.functions.invoke('send-message-notification', {
body: { conversationId, messageId: data.id },
});
} catch (err: any) {
console.error('Send error:', err);
} finally {
setSending(false);
}
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
<IonButtons slot="start">
<IonButton
onClick={() => history.goBack()}
style={{ '--color': 'var(--color-text-primary)' }}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle>
{otherUser && (
<div
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
<Avatar
src={otherUser.avatar_path}
name={otherUser.full_name}
size={32}
/>
<span style={{ fontSize: '16px', fontWeight: 700 }}>
{otherUser.full_name}
</span>
</div>
)}
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent
ref={scrollRef}
style={{ '--background': 'var(--color-bg)' }}
className="ion-padding"
>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
paddingBottom: '20px',
}}
>
{messages.map((msg) => {
const isMine = previewMode
? msg.sender_id === 'preview-me'
: msg.sender_id === user?.id;
return (
<div
key={msg.id}
style={{
alignSelf: isMine ? 'flex-end' : 'flex-start',
maxWidth: '78%',
background: isMine
? 'var(--ion-color-primary)'
: 'var(--color-surface)',
color: isMine ? '#ffffff' : 'var(--color-text-primary)',
padding: '10px 14px',
borderRadius: isMine
? '18px 18px 4px 18px'
: '18px 18px 18px 4px',
fontSize: '14px',
lineHeight: 1.4,
boxShadow: '0 2px 8px rgba(76, 5, 25, 0.02)',
}}
>
{msg.body}
</div>
);
})}
</div>
)}
</IonContent>
<div
style={{
background: '#ffffff',
padding: '10px 20px calc(10px + var(--ion-safe-area-bottom))',
borderTop: '1px solid var(--color-border)',
}}
>
<form
onSubmit={handleSend}
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
>
<IonInput
value={text}
onIonInput={(e) => setText(e.detail.value!)}
placeholder="Type a message..."
style={{
background: 'var(--color-bg)',
'--border-radius': 'var(--radius-pill)',
'--padding-start': '16px',
fontSize: '14px',
}}
/>
<IonButton
type="submit"
disabled={!text.trim() || sending}
style={{
'--border-radius': '50%',
width: '44px',
height: '44px',
margin: 0,
flexShrink: 0,
}}
>
{sending ? (
<IonSpinner name="crescent" />
) : (
<IonIcon icon={sendOutline} slot="icon-only" />
)}
</IonButton>
</form>
</div>
</IonPage>
);
};
export default ChatThreadPage;
+218
View File
@@ -0,0 +1,218 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonSpinner,
useIonViewWillEnter,
IonRefresher,
IonRefresherContent,
IonIcon,
} from '@ionic/react';
import { chatbubbleEllipsesOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { formatTime } from '../utils/format';
import SearchRow from '../components/SearchRow';
import Avatar from '../components/Avatar';
import EmptyState from '../components/EmptyState';
import { previewChats } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const ChatsPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [chats, setChats] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const fetchChats = async () => {
if (previewMode) {
setChats(previewChats);
setLoading(false);
return;
}
if (!user) return;
setLoading(true);
try {
const { data, error } = await supabase
.from('matches')
.select(
`
id,
last_message_at,
user_a:profiles!matches_user_a_fkey(id, full_name, avatar_path),
user_b:profiles!matches_user_b_fkey(id, full_name, avatar_path),
conversations(
id,
messages(
body,
created_at
)
)
`
)
.or(`user_a.eq.${user.id},user_b.eq.${user.id}`)
.order('last_message_at', { ascending: false });
if (error) throw error;
const formatted =
data?.map((m) => {
const otherUser = m.user_a.id === user.id ? m.user_b : m.user_a;
const conversation = m.conversations?.[0];
const lastMsg = conversation?.messages?.[0]; // ordered by handle-swipe or trigger later
return {
id: conversation?.id,
otherUser,
lastMessage: lastMsg?.body || 'New match! Start chatting.',
timestamp: m.last_message_at || lastMsg?.created_at,
};
}) || [];
setChats(formatted);
} catch (err: any) {
console.error('Fetch chats error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchChats();
});
const handleRefresh = async (event: CustomEvent) => {
await fetchChats();
event.detail.complete();
};
const displayChats = previewMode ? previewChats : chats;
const filteredChats = displayChats.filter(
(c) =>
c.otherUser.full_name.toLowerCase().includes(query.toLowerCase()) ||
c.lastMessage.toLowerCase().includes(query.toLowerCase())
);
return (
<IonPage>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div
style={{ padding: 'calc(16px + var(--ion-safe-area-top)) 20px 12px' }}
>
<h1
style={{
fontSize: '24px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: 0,
}}
>
Chats
</h1>
</div>
<SearchRow
value={query}
onChange={(e) => setQuery(e.detail.value!)}
placeholder="Search conversations"
/>
<div
style={{ paddingBottom: 'calc(92px + var(--ion-safe-area-bottom))' }}
>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : filteredChats.length === 0 ? (
<EmptyState
icon={chatbubbleEllipsesOutline}
title="No chats yet"
message="Matches you message will appear here. Start swiping to find someone!"
ctaText="Find people"
onCtaClick={() => history.push('/icebreaker')}
/>
) : (
filteredChats.map((chat) => (
<div
key={chat.id}
onClick={() => history.push(`/chat/${chat.id}`)}
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
margin: '0 20px 12px',
padding: '16px',
display: 'flex',
alignItems: 'center',
gap: '12px',
boxShadow: 'var(--shadow-card)',
}}
>
<Avatar
src={chat.otherUser.image || chat.otherUser.avatar_path}
name={chat.otherUser.full_name}
size={56}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '4px',
}}
>
<h3
style={{
fontSize: '16px',
fontWeight: 800,
margin: 0,
color: 'var(--color-text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{chat.otherUser.full_name}
</h3>
<span
style={{
fontSize: '11px',
color: 'var(--color-text-tertiary)',
fontWeight: 600,
}}
>
{formatTime(chat.timestamp)}
</span>
</div>
<p
style={{
fontSize: '14px',
color: 'var(--color-text-secondary)',
margin: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{chat.lastMessage}
</p>
</div>
</div>
))
)}
</div>
</IonContent>
</IonPage>
);
};
export default ChatsPage;
+378
View File
@@ -0,0 +1,378 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonItem,
IonInput,
IonTextarea,
IonSelect,
IonSelectOption,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import {
chevronBackOutline,
cameraOutline,
trashOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { getStorageUrl } from '../utils/storage';
const EditProfilePage: React.FC = () => {
const history = useHistory();
const { user, profile, refreshProfile } = useAuth();
const [fullName, setFullName] = useState(profile?.full_name || '');
const [bio, setBio] = useState(profile?.bio || '');
const [gender, setGender] = useState(profile?.gender || '');
const [relationshipGoal, setRelationshipGoal] = useState(
profile?.relationship_goal || ''
);
const [loading, setLoading] = useState(false);
const [photos, setPhotos] = useState<any[]>([]);
const [uploading, setUploading] = useState(false);
const fetchPhotos = async () => {
if (!user) return;
const { data } = await supabase
.from('profile_photos')
.select('*')
.eq('user_id', user.id)
.order('position');
setPhotos(data || []);
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
if (profile) {
setFullName(profile.full_name);
setBio(profile.bio || '');
setGender(profile.gender || '');
setRelationshipGoal(profile.relationship_goal || '');
}
fetchPhotos();
});
const handleSave = async () => {
if (!user) return;
setLoading(true);
try {
const { error } = await supabase
.from('profiles')
.update({
full_name: fullName,
bio,
gender,
relationship_goal: relationshipGoal,
})
.eq('id', user.id);
if (error) throw error;
await refreshProfile();
history.goBack();
} catch (err: any) {
console.error('Save error:', err);
} finally {
setLoading(false);
}
};
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !user) return;
setUploading(true);
try {
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
const filePath = `profile-photos/${fileName}`;
const { error: uploadError } = await supabase.storage
.from('profile-photos')
.upload(filePath, file);
if (uploadError) throw uploadError;
const { error: dbError } = await supabase.from('profile_photos').insert({
user_id: user.id,
storage_path: filePath,
position: photos.length,
});
if (dbError) throw dbError;
fetchPhotos();
} catch (err: any) {
console.error('Photo upload error:', err);
} finally {
setUploading(false);
}
};
const deletePhoto = async (id: string, path: string) => {
try {
await supabase.from('profile_photos').delete().eq('id', id);
await supabase.storage.from('profile-photos').remove([path]);
fetchPhotos();
} catch (err) {
console.error('Delete photo error:', err);
}
};
const goals = [
{ label: 'Long-term partner', value: 'long_term' },
{ label: 'Long-term, but short-term OK', value: 'long_term_short_ok' },
{ label: 'Short-term, but long-term OK', value: 'short_term_long_ok' },
{ label: 'Short-term fun', value: 'short_term' },
{ label: 'New friends', value: 'friends' },
{ label: 'Still figuring it out', value: 'figuring_it_out' },
];
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
<IonButtons slot="start">
<IonButton
onClick={() => history.goBack()}
style={{ '--color': 'var(--color-text-primary)' }}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle>Edit Profile</IonTitle>
<IonButtons slot="end">
<IonButton
onClick={handleSave}
disabled={loading}
style={{ fontWeight: 700 }}
>
{loading ? <IonSpinner name="crescent" /> : 'Save'}
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent
className="ion-padding"
style={{ '--background': 'var(--color-bg)' }}
>
{/* Photo Manager */}
<div style={{ marginBottom: '24px' }}>
<h3
style={{
fontSize: '13px',
fontWeight: 800,
color: 'var(--color-text-tertiary)',
textTransform: 'uppercase',
marginBottom: '12px',
}}
>
Profile Photos
</h3>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '10px',
}}
>
{photos.map((photo) => (
<div
key={photo.id}
style={{
paddingBottom: '125%',
background: 'var(--color-surface-raised)',
borderRadius: '12px',
position: 'relative',
overflow: 'hidden',
}}
>
<img
src={
getStorageUrl('profile-photos', photo.storage_path) || ''
}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
alt="Profile"
/>
<button
onClick={() => deletePhoto(photo.id, photo.storage_path)}
style={{
position: 'absolute',
top: '8px',
right: '8px',
background: 'rgba(0,0,0,0.5)',
border: 'none',
borderRadius: '50%',
width: '24px',
height: '24px',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon icon={trashOutline} style={{ fontSize: '14px' }} />
</button>
</div>
))}
{photos.length < 6 && (
<label
style={{
paddingBottom: '125%',
background: 'var(--color-surface)',
borderRadius: '12px',
position: 'relative',
border: '2px dashed var(--ion-color-primary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{uploading ? (
<IonSpinner name="crescent" />
) : (
<IonIcon
icon={cameraOutline}
style={{
fontSize: '24px',
color: 'var(--ion-color-primary)',
}}
/>
)}
</div>
<input
type="file"
accept="image/*"
onChange={handlePhotoUpload}
hidden
disabled={uploading}
/>
</label>
)}
</div>
</div>
{/* Basic Info */}
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '16px',
boxShadow: 'var(--shadow-card)',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="Full Name"
labelPlacement="floating"
value={fullName}
onIonInput={(e) => setFullName(e.detail.value!)}
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Gender"
labelPlacement="floating"
value={gender}
onIonChange={(e) => setGender(e.detail.value)}
interface="action-sheet"
>
<IonSelectOption value="woman">Woman</IonSelectOption>
<IonSelectOption value="man">Man</IonSelectOption>
<IonSelectOption value="nonbinary">Non-binary</IonSelectOption>
<IonSelectOption value="other">Other</IonSelectOption>
<IonSelectOption value="prefer_not_to_say">
Prefer not to say
</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Relationship Goal"
labelPlacement="floating"
value={relationshipGoal}
onIonChange={(e) => setRelationshipGoal(e.detail.value)}
interface="action-sheet"
>
{goals.map((g) => (
<IonSelectOption key={g.value} value={g.value}>
{g.label}
</IonSelectOption>
))}
</IonSelect>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonTextarea
label="Bio"
labelPlacement="floating"
value={bio}
onIonInput={(e) => setBio(e.detail.value!)}
placeholder="Tell us about yourself..."
rows={4}
/>
</IonItem>
</div>
</IonContent>
</IonPage>
);
};
export default EditProfilePage;
-380
View File
@@ -1,380 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonSpinner,
} from '@ionic/react';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { ChurchEvent } from '../types';
import { useAuth } from '../contexts/AuthContext';
import {
chevronBackOutline,
createOutline,
timeOutline,
locationOutline,
calendarOutline,
} from 'ionicons/icons';
const EventDetail: React.FC = () => {
const [event, setEvent] = useState<ChurchEvent | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { id } = useParams<{ id: string }>();
const history = useHistory();
const { profile } = useAuth();
const loadEvent = async () => {
setLoading(true);
setError(null);
const { data, error: fetchError } = await supabase
.from('events')
.select('*, event_categories(*)')
.eq('id', id)
.single();
if (fetchError) {
setError(fetchError.message);
setEvent(null);
} else {
setEvent(data as ChurchEvent);
}
setLoading(false);
};
useEffect(() => {
loadEvent();
}, [id]);
const goToEdit = () => {
if (!event) return;
history.push(`/admin/events/${event.id}/edit`);
};
const isAdmin = profile?.role === 'admin';
return (
<IonPage style={{ background: '#f5f7f4' }}>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonButtons slot="start">
<IonButton onClick={() => history.goBack()}>
<IonIcon
icon={chevronBackOutline}
style={{ color: '#1f2937' }}
slot="icon-only"
/>
</IonButton>
</IonButtons>
<IonTitle
style={{ fontSize: '18px', fontWeight: '700', color: '#1f2937' }}
>
Event Detail
</IonTitle>
{isAdmin && (
<IonButtons slot="end">
<IonButton onClick={goToEdit}>
<IonIcon
icon={createOutline}
style={{ color: '#2f6b4f', fontSize: '20px' }}
slot="icon-only"
/>
</IonButton>
</IonButtons>
)}
</IonToolbar>
</IonHeader>
<IonContent
style={{
'--background': '#f5f7f4',
'--padding-start': '20px',
'--padding-end': '20px',
'--padding-top': '16px',
'--padding-bottom': '24px',
}}
>
{loading ? (
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '40px 0',
}}
>
<IonSpinner name="crescent" color="primary" />
</div>
) : error || !event ? (
<div
style={{
padding: '12px',
background: 'rgba(190,60,60,0.1)',
borderRadius: '12px',
color: '#a33b32',
fontSize: '13px',
}}
>
{error || 'Event not found'}
</div>
) : (
<>
{/* Header Detail Card */}
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '24px',
margin: '0 0 16px 0',
display: 'flex',
flexDirection: 'column',
gap: '10px',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<div style={{ display: 'flex' }}>
<div
style={{
background: 'rgba(47, 107, 79, 0.10)',
color: '#2f6b4f',
borderRadius: '999px',
padding: '5px 12px',
fontSize: '12px',
fontWeight: '600',
}}
>
{event.event_categories?.name || 'Event'}
</div>
</div>
<h1
style={{
margin: 0,
fontSize: '24px',
fontWeight: '700',
color: '#1f2937',
}}
>
{event.title}
</h1>
</div>
{/* Info Rows Card */}
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '20px',
margin: '0 0 16px 0',
display: 'flex',
flexDirection: 'column',
gap: '14px',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
{/* Date Row */}
<div
style={{ display: 'flex', alignItems: 'center', gap: '12px' }}
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '12px',
background: 'rgba(47, 107, 79, 0.12)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={calendarOutline}
style={{ color: '#2f6b4f', fontSize: '18px' }}
/>
</div>
<div>
<div
style={{
fontSize: '12px',
fontWeight: '600',
color: '#7a847f',
}}
>
Date
</div>
<div
style={{
fontSize: '15px',
fontWeight: '600',
color: '#1f2937',
marginTop: '2px',
}}
>
{new Date(event.start_at).toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</div>
</div>
</div>
{/* Time Row */}
{(() => {
const date = new Date(event.start_at);
const hasTime =
date.getHours() !== 0 || date.getMinutes() !== 0;
if (!hasTime) return null;
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '12px',
background: 'rgba(47, 107, 79, 0.12)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={timeOutline}
style={{ color: '#2f6b4f', fontSize: '18px' }}
/>
</div>
<div>
<div
style={{
fontSize: '12px',
fontWeight: '600',
color: '#7a847f',
}}
>
Time
</div>
<div
style={{
fontSize: '15px',
fontWeight: '600',
color: '#1f2937',
marginTop: '2px',
}}
>
{date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
{event.end_at &&
` - ${new Date(event.end_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
</div>
</div>
</div>
);
})()}
{/* Location Row */}
<div
style={{ display: 'flex', alignItems: 'center', gap: '12px' }}
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '12px',
background: 'rgba(47, 107, 79, 0.12)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={locationOutline}
style={{ color: '#2f6b4f', fontSize: '18px' }}
/>
</div>
<div>
<div
style={{
fontSize: '12px',
fontWeight: '600',
color: '#7a847f',
}}
>
Location
</div>
<div
style={{
fontSize: '15px',
fontWeight: '600',
color: '#1f2937',
marginTop: '2px',
}}
>
{event.venue}
</div>
</div>
</div>
</div>
{/* About Card */}
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '20px',
margin: '0 0 16px 0',
display: 'flex',
flexDirection: 'column',
gap: '10px',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<h3
style={{
margin: 0,
fontSize: '16px',
fontWeight: '700',
color: '#1f2937',
}}
>
About
</h3>
<p
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: '#4b5563',
lineHeight: '1.6',
whiteSpace: 'pre-wrap',
}}
>
{event.description}
</p>
</div>
</>
)}
</IonContent>
</IonPage>
);
};
export default EventDetail;
-350
View File
@@ -1,350 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonSpinner,
useIonViewWillEnter,
IonIcon,
IonRefresher,
IonRefresherContent,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { ChurchEvent } from '../types';
import EventListCard from '../components/EventListCard';
import {
searchOutline,
filterOutline,
calendarClearOutline,
} from 'ionicons/icons';
const Events: React.FC = () => {
const [allEvents, setAllEvents] = useState<ChurchEvent[]>([]);
const [events, setEvents] = useState<ChurchEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [activeCategory, setActiveCategory] = useState<
'all' | 'weekly' | 'youth' | 'sunday-school'
>('all');
const history = useHistory();
const loadEvents = async (event?: any) => {
if (!event) setLoading(true);
setError(null);
const now = new Date();
now.setDate(now.getDate() - 1);
const dateString = now.toISOString();
const { data, error: fetchError } = await supabase
.from('events')
.select('*, event_categories(*)')
.gte('start_at', dateString)
.order('start_at', { ascending: true });
if (fetchError) {
setError(fetchError.message);
setAllEvents([]);
setEvents([]);
} else {
const fetched = data as ChurchEvent[];
setAllEvents(fetched);
applyFilters(fetched, activeCategory, searchTerm);
}
setLoading(false);
if (event) {
event.detail.complete();
}
};
useIonViewWillEnter(() => {
loadEvents();
});
const applyFilters = (
source: ChurchEvent[],
category: 'all' | 'weekly' | 'youth' | 'sunday-school',
search: string
) => {
let filtered = [...source];
if (category === 'weekly') {
filtered = filtered.filter((e) => e.event_categories?.slug === 'weekly');
} else if (category === 'youth') {
filtered = filtered.filter(
(e) => e.event_categories?.slug === 'youth' || e.audience === 'youth'
);
} else if (category === 'sunday-school') {
filtered = filtered.filter(
(e) => e.event_categories?.slug === 'sunday-school'
);
}
if (search.trim()) {
const term = search.toLowerCase();
filtered = filtered.filter(
(e) =>
e.title.toLowerCase().includes(term) ||
e.description.toLowerCase().includes(term) ||
e.venue.toLowerCase().includes(term)
);
}
setEvents(filtered);
};
const handleCategoryChange = (
category: 'all' | 'weekly' | 'youth' | 'sunday-school'
) => {
setActiveCategory(category);
applyFilters(allEvents, category, searchTerm);
};
const handleSearchChange = (val: string) => {
setSearchTerm(val);
applyFilters(allEvents, activeCategory, val);
};
const openEvent = (id: string) => {
history.push(`/event/${id}`);
};
return (
<IonPage style={{ background: '#f5f7f4' }}>
<IonContent
style={{
'--background': '#f5f7f4',
'--padding-start': '0px',
'--padding-end': '0px',
'--padding-top': '0px',
'--padding-bottom': '24px',
}}
>
<IonRefresher slot="fixed" onIonRefresh={loadEvents}>
<IonRefresherContent />
</IonRefresher>
{/* Top Title Row */}
<div
style={{
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 8px',
margin: '0',
background: 'transparent',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
<h1
style={{
margin: 0,
fontSize: '24px',
fontWeight: '700',
color: '#1f2937',
}}
>
Events
</h1>
<p
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: '#6b7280',
}}
>
Discover what's happening at church
</p>
</div>
{/* Search Row */}
<div style={{ display: 'flex', gap: '8px', margin: '0 20px 12px' }}>
<div
style={{
flex: 1,
background: '#ffffff',
borderRadius: '24px',
height: '44px',
display: 'flex',
alignItems: 'center',
padding: '0 16px',
gap: '10px',
}}
>
<IonIcon
icon={searchOutline}
style={{ color: '#9aa3a0', fontSize: '18px', flexShrink: 0 }}
/>
<input
type="text"
placeholder="Search events..."
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
style={{
flex: 1,
border: 'none',
background: 'transparent',
outline: 'none',
fontSize: '15px',
color: '#1f2937',
}}
/>
</div>
<div
onClick={() => handleCategoryChange('all')}
style={{
width: '44px',
height: '44px',
background: '#ffffff',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
cursor: 'pointer',
}}
>
<IonIcon
icon={filterOutline}
style={{ color: '#2f6b4f', fontSize: '20px' }}
/>
</div>
</div>
{/* Category Chips */}
<div
style={{
display: 'flex',
gap: '8px',
margin: '0 20px 12px',
overflowX: 'auto',
paddingBottom: '4px',
WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none',
}}
>
{['all', 'weekly', 'youth', 'sunday-school'].map((cat) => (
<button
key={cat}
onClick={() => handleCategoryChange(cat as any)}
style={{
background: activeCategory === cat ? '#2f6b4f' : '#ffffff',
color: activeCategory === cat ? '#ffffff' : '#4b5563',
border: 'none',
borderRadius: '999px',
padding: '8px 14px',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
whiteSpace: 'nowrap',
boxShadow:
activeCategory === cat
? 'none'
: '0 1px 2px rgba(0,0,0,0.02)',
}}
>
{cat === 'all'
? 'All Events'
: cat === 'sunday-school'
? 'Sunday School'
: cat.charAt(0).toUpperCase() + cat.slice(1)}
</button>
))}
</div>
{/* Main Content */}
{loading ? (
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '40px 0',
}}
>
<IonSpinner name="crescent" color="primary" />
</div>
) : error ? (
<div
style={{
margin: '0 20px',
padding: '12px',
background: 'rgba(190,60,60,0.1)',
borderRadius: '12px',
color: '#a33b32',
fontSize: '13px',
}}
>
{error}
</div>
) : events.length > 0 ? (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '12px',
margin: '0 20px',
}}
>
{events.map((event) => (
<EventListCard
key={event.id}
event={event}
onClick={() => openEvent(event.id)}
/>
))}
</div>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '60px 20px',
textAlign: 'center',
gap: '12px',
}}
>
<IonIcon
icon={calendarClearOutline}
style={{ fontSize: '44px', color: '#7a847f' }}
/>
<p
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: '#6b7280',
}}
>
No events match this filter.
</p>
<button
onClick={() => {
setSearchTerm('');
handleCategoryChange('all');
}}
style={{
background: 'transparent',
border: 'none',
fontSize: '14px',
fontWeight: '600',
color: '#2f6b4f',
cursor: 'pointer',
marginTop: '4px',
}}
>
Show all events
</button>
</div>
)}
</IonContent>
</IonPage>
);
};
export default Events;
+153
View File
@@ -0,0 +1,153 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonInput,
IonItem,
IonButton,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const ForgotPasswordPage: React.FC = () => {
const history = useHistory();
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleReset = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const { error } = await supabase.auth.resetPasswordForEmail(email);
if (error) throw error;
history.push('/verify-email', { email, type: 'recovery' });
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background': 'var(--color-bg)',
}}
>
<div style={{ padding: 'calc(var(--ion-safe-area-top) + 40px) 20px' }}>
<div style={{ marginBottom: '32px' }}>
<h1
style={{
fontSize: '28px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 8px',
}}
>
Reset Password
</h1>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: 0,
lineHeight: 1.5,
}}
>
Enter your email and we'll send you a code to reset your password.
</p>
</div>
<form
onSubmit={handleReset}
style={{
background: 'var(--color-surface)',
padding: '24px',
borderRadius: 'var(--radius-card)',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="Email"
labelPlacement="floating"
type="email"
value={email}
onIonInput={(e) => setEmail(e.detail.value!)}
required
/>
</IonItem>
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
}}
>
{error}
</div>
)}
<IonButton
type="submit"
expand="block"
disabled={loading}
style={{
'--border-radius': 'var(--radius-pill)',
height: '48px',
marginTop: '8px',
}}
>
{loading ? <IonSpinner name="crescent" /> : 'Send Code'}
</IonButton>
<button
type="button"
onClick={() => history.goBack()}
style={{
background: 'none',
border: 'none',
color: 'var(--color-text-tertiary)',
fontSize: '14px',
fontWeight: 600,
marginTop: '12px',
}}
>
Back to Sign In
</button>
</form>
</div>
</IonContent>
</IonPage>
);
};
export default ForgotPasswordPage;
+558
View File
@@ -0,0 +1,558 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonIcon,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonRefresher,
IonRefresherContent,
} from '@ionic/react';
import {
locationOutline,
notificationsOutline,
star,
cameraOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { formatDistance } from '../utils/format';
import { getStorageUrl } from '../utils/storage';
import SearchRow from '../components/SearchRow';
import EmptyState from '../components/EmptyState';
import { previewPlaces } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const HangoutPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [places, setPlaces] = useState<any[]>([]);
const [reelStories, setReelStories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const [sort, setSort] = useState<'most_busy' | 'nearest' | 'top_rated'>(
'most_busy'
);
const fetchPlaces = async () => {
setLoading(true);
if (previewMode) {
setPlaces(previewPlaces);
setReelStories(previewPlaces);
setLoading(false);
return;
}
try {
// Phase 1: simple local query. Phase 2: use places-nearby Edge Function
const { data, error } = await supabase
.from('places')
.select(
`
*,
reels(id)
`
)
.order('rating', { ascending: false });
if (error) throw error;
setPlaces(data || []);
// Derive stories (places with reels)
const stories =
data?.filter((p) => p.reels?.length > 0).slice(0, 10) || [];
setReelStories(stories);
} catch (err: any) {
console.error('Fetch places error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchPlaces();
});
const handleRefresh = async (event: CustomEvent) => {
await fetchPlaces();
event.detail.complete();
};
const displayPlaces = previewMode ? previewPlaces : places;
const displayStories = previewMode ? previewPlaces : reelStories;
const filteredPlaces = displayPlaces.filter(
(p) =>
p.name.toLowerCase().includes(query.toLowerCase()) ||
p.category.toLowerCase().includes(query.toLowerCase())
);
return (
<IonPage>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div
style={{
padding: 'calc(14px + var(--ion-safe-area-top)) 20px 8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '12px',
background: 'rgba(255,255,255,0.06)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={locationOutline}
style={{ color: 'var(--ion-color-primary)', fontSize: '18px' }}
/>
</div>
<div
style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}
>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--color-text-tertiary)',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}
>
Nearby now
</span>
<span
style={{
fontSize: '17px',
fontWeight: 700,
color: 'var(--color-text-primary)',
lineHeight: 1.1,
}}
>
Midrand, ZA
</span>
</div>
</div>
<IonButton
fill="clear"
onClick={() => history.push('/notifications')}
style={{
'--background': 'rgba(255,255,255,0.06)',
'--color': 'var(--color-text-primary)',
'--border-radius': '12px',
width: '44px',
height: '44px',
margin: 0,
'--padding-start': 0,
'--padding-end': 0,
}}
>
<IonIcon
icon={notificationsOutline}
slot="icon-only"
style={{ fontSize: '20px' }}
/>
</IonButton>
</div>
<SearchRow
value={query}
onChange={(e) => setQuery(e.detail.value!)}
placeholder="Search place of interest"
/>
<div
style={{
display: 'flex',
gap: '10px',
padding: '2px 20px 26px',
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}
>
{(['most_busy', 'nearest', 'top_rated'] as const).map((s) => {
const active = sort === s;
return (
<button
key={s}
onClick={() => setSort(s)}
style={{
padding: '10px 16px',
borderRadius: 'var(--radius-pill)',
border: 'none',
fontSize: '14px',
fontWeight: active ? 700 : 600,
whiteSpace: 'nowrap',
background: active
? 'linear-gradient(180deg, #f24f88 0%, var(--ion-color-primary) 100%)'
: 'rgba(255,255,255,0.05)',
color: active ? '#ffffff' : 'rgba(255,255,255,0.82)',
boxShadow: active
? '0 10px 24px rgba(254, 60, 114, 0.26)'
: 'none',
transition: '0.2s ease',
flexShrink: 0,
}}
>
{s.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
</button>
);
})}
</div>
{/* Reels Stories Section */}
<div style={{ marginBottom: '26px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0 20px 14px',
}}
>
<h2
style={{
fontSize: '18px',
fontWeight: 800,
margin: 0,
color: 'var(--color-text-primary)',
letterSpacing: '-0.02em',
}}
>
Reels
</h2>
<button
onClick={() => history.push('/camera')}
style={{
width: '44px',
height: '44px',
borderRadius: '12px',
border: 'none',
background:
'linear-gradient(180deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.06) 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
flexShrink: 0,
}}
>
<IonIcon
icon={cameraOutline}
style={{ fontSize: '20px', color: 'var(--color-text-primary)' }}
/>
</button>
</div>
<div
style={{
display: 'flex',
gap: '14px',
padding: '0 20px',
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}
>
<div style={{ flexShrink: 0, width: '76px', textAlign: 'center' }}>
<div
onClick={() => history.push('/camera')}
style={{
width: '68px',
height: '68px',
borderRadius: '50%',
padding: '2px',
background:
'linear-gradient(180deg, #f24f88 0%, #a60b4a 100%)',
marginBottom: '8px',
}}
>
<div
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
background: '#08080a',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon
icon={cameraOutline}
style={{
fontSize: '22px',
color: 'var(--ion-color-primary)',
}}
/>
</div>
</div>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--color-text-primary)',
opacity: 0.95,
}}
>
My reels
</span>
</div>
{displayStories.map((story) => (
<div
key={story.id}
onClick={() => history.push(`/reels/${story.id}`)}
style={{ flexShrink: 0, width: '76px', textAlign: 'center' }}
>
<div
style={{
width: '68px',
height: '68px',
borderRadius: '50%',
padding: '2px',
background:
'linear-gradient(180deg, #f24f88 0%, #a60b4a 100%)',
marginBottom: '8px',
}}
>
<div
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
padding: '3px',
background: '#08080a',
}}
>
<img
src={
story.image ||
getStorageUrl('place-media', story.image_path) ||
''
}
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
objectFit: 'cover',
}}
alt={story.name}
/>
</div>
</div>
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--color-text-primary)',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
opacity: 0.95,
}}
>
{story.name}
</span>
</div>
))}
</div>
</div>
{/* Places List */}
<div
style={{ paddingBottom: 'calc(92px + var(--ion-safe-area-bottom))' }}
>
<h2
style={{
fontSize: '18px',
fontWeight: 800,
margin: '0 20px 16px',
color: 'var(--color-text-primary)',
}}
>
Places of interest 📍
</h2>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : filteredPlaces.length === 0 ? (
<EmptyState
icon={locationOutline}
title="No places found"
message="We couldn't find any places matching your search nearby."
ctaText="Refresh"
onCtaClick={fetchPlaces}
/>
) : (
filteredPlaces.map((place, index) => (
<div
key={place.id}
onClick={() => history.push(`/reels/${place.id}`)}
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
margin: '0 20px 12px',
padding: '12px',
display: 'flex',
gap: '12px',
boxShadow: 'var(--shadow-card)',
}}
>
<div
style={{
flexShrink: 0,
width: '112px',
height: '88px',
borderRadius: 'var(--radius-control)',
overflow: 'hidden',
}}
>
<img
src={
place.image ||
getStorageUrl('place-media', place.image_path) ||
''
}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
alt={place.name}
/>
</div>
<div style={{ flex: 1, position: 'relative' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<h3
style={{
fontSize: '16px',
fontWeight: 800,
margin: '0 0 4px',
color: 'var(--color-text-primary)',
}}
>
{index + 1}. {place.name}
</h3>
</div>
<p
style={{
fontSize: '12px',
color: 'var(--color-text-secondary)',
margin: '0 0 4px',
}}
>
{place.category}
</p>
<p
style={{
fontSize: '12px',
color: 'var(--color-text-tertiary)',
margin: '0 0 8px',
}}
>
{place.distance || '0.9 mi'}
</p>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<div style={{ display: 'flex', gap: '1px' }}>
{[1, 2, 3, 4, 5].map((s) => (
<IonIcon
key={s}
icon={star}
style={{
fontSize: '12px',
color:
s <= place.rating
? 'var(--ion-color-primary)'
: 'var(--color-border)',
}}
/>
))}
</div>
<span
style={{
fontSize: '11px',
color: 'var(--color-text-tertiary)',
fontWeight: 600,
}}
>
{place.review_count > 1000
? `${(place.review_count / 1000).toFixed(1)}k`
: place.review_count}{' '}
Reviews
</span>
</div>
{place.busyness === 'busy' && (
<div
style={{
position: 'absolute',
top: 0,
right: 0,
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<span
style={{
fontSize: '10px',
color: 'var(--color-text-tertiary)',
fontWeight: 600,
}}
>
Busy
</span>
<div
style={{
background: 'var(--ion-color-primary)',
color: '#ffffff',
fontSize: '9px',
fontWeight: 800,
padding: '2px 6px',
borderRadius: 'var(--radius-pill)',
}}
>
LIVE
</div>
</div>
)}
</div>
</div>
))
)}
</div>
</IonContent>
</IonPage>
);
};
export default HangoutPage;
+20 -581
View File
@@ -1,591 +1,30 @@
import React, { useState } from 'react'; import React from "react";
import { import {
IonPage,
IonContent, IonContent,
IonSpinner, IonHeader,
useIonViewWillEnter, IonPage,
IonIcon, IonTitle,
} from '@ionic/react'; IonToolbar,
import { useHistory } from 'react-router-dom'; } from "@ionic/react";
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { ChurchEvent } from '../types';
import EventPreviewCard from '../components/EventPreviewCard';
import HomeStatCard from '../components/HomeStatCard';
import {
calendarOutline,
peopleOutline,
newspaperOutline,
} from 'ionicons/icons';
const Home: React.FC = () => { const Home: React.FC = () => (
const [featuredEvent, setFeaturedEvent] = useState<ChurchEvent | null>(null); <IonPage>
const [upcomingEvents, setUpcomingEvents] = useState<ChurchEvent[]>([]); <IonHeader translucent className="home-header">
const [latestNews, setLatestNews] = useState<any[]>([]); <IonToolbar className="home-toolbar">
const [weeklyCount, setWeeklyCount] = useState(0); <IonTitle>Home</IonTitle>
const [youthCount, setYouthCount] = useState(0); </IonToolbar>
const [loading, setLoading] = useState(true); </IonHeader>
const [error, setError] = useState<string | null>(null); <IonContent fullscreen className="home-content">
<div className="home-shell minimal-home-shell">
const { profile } = useAuth(); <section className="minimal-welcome-card subtle-welcome-card">
const history = useHistory(); <h1>Welcome to your new app</h1>
<p className="minimal-subtitle">
const loadHomeData = async () => { Chat with the assistant to start adding features and pages
setLoading(true);
setError(null);
const now = new Date().toISOString();
const [{ data, error: fetchError }, { data: newsData, error: newsError }] =
await Promise.all([
supabase
.from('events')
.select('*, event_categories(*)')
.gte('start_at', now)
.order('start_at', { ascending: true })
.limit(20),
supabase
.from('news')
.select('*')
.order('created_at', { ascending: false })
.limit(3),
]);
if (newsData) {
setLatestNews(newsData);
}
if (fetchError) {
setError(fetchError.message);
setFeaturedEvent(null);
setUpcomingEvents([]);
setWeeklyCount(0);
setYouthCount(0);
setLoading(false);
return;
}
const allUpcoming = data as ChurchEvent[];
if (allUpcoming.length > 0) {
const featured = allUpcoming.find((e) => e.is_featured) || allUpcoming[0];
setFeaturedEvent(featured);
setUpcomingEvents(allUpcoming.slice(0, 4));
} else {
setFeaturedEvent(null);
setUpcomingEvents([]);
}
const weekly = allUpcoming.filter(
(e) => e.event_categories?.slug === 'weekly'
).length;
const youth = allUpcoming.filter(
(e) => e.audience === 'youth' || e.event_categories?.slug === 'youth'
).length;
setWeeklyCount(weekly);
setYouthCount(youth);
setLoading(false);
};
useIonViewWillEnter(() => {
loadHomeData();
});
const openEventDetail = (id: string) => {
history.push(`/event/${id}`);
};
const goToEvents = () => {
history.push('/events');
};
const initials = profile?.full_name
? profile.full_name
.split(' ')
.map((n) => n[0])
.join('')
.substring(0, 2)
.toUpperCase()
: 'U';
return (
<IonPage style={{ background: 'var(--color-bg)' }}>
<IonContent
style={{
'--background': 'linear-gradient(180deg, #f8f4ef 0%, #f3ece3 100%)',
'--padding-start': '0px',
'--padding-end': '0px',
'--padding-top': '0px',
'--padding-bottom': '24px',
}}
>
{/* Top Content Row */}
<div
style={{
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 12px',
margin: '0',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
}}
>
<div>
<div
style={{
fontSize: '12px',
fontWeight: '600',
color: 'var(--color-text-secondary)',
textTransform: 'uppercase',
}}
>
Welcome to
</div>
<div
style={{
fontSize: '18px',
fontWeight: '700',
color: 'var(--color-text-primary)',
}}
>
EAGLES ASSEMBLY
</div>
</div>
<div
onClick={() => history.push('/profile')}
style={{
width: '40px',
height: '40px',
borderRadius: '12px',
background: 'var(--color-brand-soft)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontWeight: '700',
color: 'var(--color-brand)',
cursor: 'pointer',
}}
>
{initials}
</div>
</div>
{/* Greeting Widget */}
<div
style={{
background: 'var(--color-surface)',
borderRadius: '24px',
padding: '22px',
margin: '0 20px 12px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'baseline',
gap: '6px',
flexWrap: 'wrap',
}}
>
<span
style={{
fontSize: '22px',
fontWeight: '400',
color: 'var(--color-text-primary)',
}}
>
Hello,
</span>
<span
style={{
fontSize: '22px',
fontWeight: '700',
color: 'var(--color-text-primary)',
}}
>
{profile?.full_name?.split(' ')[0] || 'Friend'}
</span>
</div>
<div
style={{
fontSize: '14px',
fontWeight: '400',
color: 'var(--color-text-secondary)',
}}
>
Here is what's happening at church this week.
</div>
</div>
{loading ? (
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '40px 0',
}}
>
<IonSpinner name="crescent" color="primary" />
</div>
) : (
<>
{/* Feature Upcoming Event Widget */}
{featuredEvent && (
<div
onClick={() => openEventDetail(featuredEvent.id)}
style={{
background: 'var(--color-brand)',
borderRadius: '24px',
padding: '22px',
margin: '0 20px 12px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
cursor: 'pointer',
}}
>
<div
style={{
fontSize: '11px',
fontWeight: '600',
color: 'rgba(255,255,255,0.70)',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}
>
Next Up
</div>
<div
style={{
fontSize: '24px',
fontWeight: '700',
color: '#ffffff',
lineHeight: '1.2',
}}
>
{featuredEvent.title}
</div>
<div
style={{
fontSize: '13px',
fontWeight: '500',
color: 'rgba(255,255,255,0.85)',
}}
>
{new Date(featuredEvent.start_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}{' '}
• {featuredEvent.day_label}
</div>
<div style={{ marginTop: '4px' }}>
<button
style={{
background: 'rgba(255,255,255,0.16)',
border: 'none',
borderRadius: '999px',
padding: '10px 16px',
fontSize: '13px',
fontWeight: '600',
color: '#ffffff',
}}
>
View Details
</button>
</div>
</div>
)}
{/* Latest News Row (Horizontal Scroll) */}
{latestNews.length > 0 && (
<div style={{ marginBottom: '24px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0 20px 12px',
}}
>
<div
style={{
fontSize: '16px',
fontWeight: '700',
color: 'var(--color-text-primary)',
}}
>
Latest News
</div>
<div
onClick={() => history.push('/news')}
style={{
fontSize: '14px',
fontWeight: '600',
color: 'var(--color-brand)',
cursor: 'pointer',
}}
>
See all
</div>
</div>
<div
style={{
display: 'flex',
overflowX: 'auto',
scrollSnapType: 'x mandatory',
padding: '0 20px',
gap: '12px',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
className="hide-scrollbar"
>
{latestNews.map((news) => (
<div
key={news.id}
onClick={() => history.push(`/news/${news.id}`)}
style={{
minWidth: '260px',
maxWidth: '260px',
background: 'var(--color-surface)',
borderRadius: '24px',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
scrollSnapAlign: 'start',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
cursor: 'pointer',
}}
>
{news.image_url ? (
<img
src={news.image_url}
alt=""
style={{
width: '100%',
height: '120px',
objectFit: 'cover',
borderRadius: '16px',
background: 'var(--color-surface-raised)',
}}
/>
) : (
<div
style={{
width: '100%',
height: '120px',
borderRadius: '16px',
background: 'var(--color-brand-soft)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon
icon={newspaperOutline}
style={{
color: 'var(--color-brand)',
fontSize: '32px',
}}
/>
</div>
)}
<div>
<div
style={{
fontSize: '12px',
fontWeight: '600',
color: 'var(--color-text-tertiary)',
marginBottom: '4px',
}}
>
{new Date(news.created_at).toLocaleDateString(
undefined,
{
month: 'short',
day: 'numeric',
}
)}
</div>
<div
style={{
fontSize: '15px',
fontWeight: '700',
color: 'var(--color-text-primary)',
lineHeight: '1.3',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{news.title}
</div>
</div>
</div>
))}
<div style={{ minWidth: '8px' }}></div>
</div>
<style>{`
.hide-scrollbar::-webkit-scrollbar { display: none; }
`}</style>
</div>
)}
{/* Two Stat Widgets */}
<div
style={{ display: 'flex', gap: '12px', margin: '0 20px 12px' }}
>
<HomeStatCard
label="Weekly"
value={weeklyCount.toString()}
caption="Upcoming events"
icon={calendarOutline}
/>
<HomeStatCard
label="Youth"
value={youthCount.toString()}
caption="Activities planned"
icon={peopleOutline}
/>
</div>
{/* Upcoming This Week List */}
{upcomingEvents.length > 0 ? (
<div
style={{
background: '#fcf8f0',
borderRadius: '24px',
padding: '20px',
margin: '0 20px 16px',
display: 'flex',
flexDirection: 'column',
gap: '14px',
boxShadow: '0 2px 8px rgba(123, 45, 38, 0.05)',
border: '1px solid rgba(176, 122, 58, 0.1)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div
style={{
fontSize: '16px',
fontWeight: '700',
color: 'var(--color-text-primary)',
}}
>
Upcoming Schedule
</div>
<div
onClick={goToEvents}
style={{
fontSize: '14px',
fontWeight: '600',
color: 'var(--color-brand)',
cursor: 'pointer',
}}
>
See all
</div>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
}}
>
{upcomingEvents.map((event) => (
<EventPreviewCard
key={event.id}
event={event}
onClick={() => openEventDetail(event.id)}
/>
))}
</div>
</div>
) : (
<div
style={{
background: 'var(--color-surface)',
borderRadius: '24px',
padding: '32px 20px',
margin: '0 20px 16px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '12px',
textAlign: 'center',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<IonIcon
icon={calendarOutline}
style={{
fontSize: '40px',
color: 'var(--color-text-tertiary)',
}}
/>
<p
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: 'var(--color-text-secondary)',
}}
>
No church events have been posted yet.
</p> </p>
<button </section>
onClick={goToEvents}
style={{
background: 'var(--color-brand-soft)',
border: 'none',
borderRadius: '999px',
padding: '10px 16px',
fontSize: '14px',
fontWeight: '600',
color: 'var(--color-brand)',
cursor: 'pointer',
marginTop: '4px',
}}
>
Go to events
</button>
</div> </div>
)}
</>
)}
{error && (
<div
style={{
margin: '0 20px',
padding: '12px',
background: 'rgba(190,60,60,0.1)',
borderRadius: '12px',
color: '#a33b32',
fontSize: '13px',
}}
>
{error}
</div>
)}
</IonContent> </IonContent>
</IonPage> </IonPage>
); );
};
export default Home; export default Home;
+699
View File
@@ -0,0 +1,699 @@
import React, { useRef, useState } from 'react';
import {
IonButton,
IonContent,
IonIcon,
IonPage,
useIonViewWillEnter,
} from '@ionic/react';
import {
apps,
close,
heart,
optionsOutline,
paperPlane,
refreshOutline,
walk,
} from 'ionicons/icons';
import Lottie from 'lottie-react';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { getStorageUrl } from '../utils/storage';
import { previewProfiles } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
import heartsAnimation from '../assets/images/hearts.json';
type SwipeAction = 'like' | 'skip';
type DragState = {
x: number;
y: number;
startX: number;
startY: number;
active: boolean;
};
const SWIPE_THRESHOLD = 105;
const EXIT_DISTANCE = 620;
const calculateAge = (birthDate?: string | null, fallback?: number) => {
if (fallback) return fallback;
if (!birthDate) return null;
const birth = new Date(birthDate);
if (Number.isNaN(birth.getTime())) return null;
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age -= 1;
}
return age;
};
const IcebreakerPage: React.FC = () => {
const { user } = useAuth();
const previewMode = isPreviewMode();
const cardRef = useRef<HTMLDivElement | null>(null);
const [profiles, setProfiles] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
const [swiping, setSwiping] = useState(false);
const [showHearts, setShowHearts] = useState(false);
const [drag, setDrag] = useState<DragState | null>(null);
const [exitDirection, setExitDirection] = useState<SwipeAction | null>(null);
const fetchProfiles = async () => {
if (previewMode) {
setProfiles(previewProfiles);
setCurrentIndex(0);
setLoading(false);
return;
}
if (!user) {
setLoading(false);
return;
}
setLoading(true);
try {
const { data: swipedData } = await supabase
.from('swipes')
.select('swiped_id')
.eq('swiper_id', user.id);
const swipedIds = swipedData?.map((s) => s.swiped_id) || [];
const excludedIds = [user.id, ...swipedIds].filter(Boolean);
let query = supabase
.from('profiles')
.select('*, profile_photos(storage_path, position)')
.neq('id', user.id)
.limit(20);
if (excludedIds.length > 0) {
query = query.not('id', 'in', `(${excludedIds.join(',')})`);
}
const { data, error } = await query;
if (error) throw error;
setProfiles(data || []);
setCurrentIndex(0);
} catch (err) {
console.error('Profile fetch error:', err);
setProfiles([]);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchProfiles();
});
const displayProfiles = previewMode ? previewProfiles : profiles;
const currentProfile = displayProfiles.length
? displayProfiles[currentIndex % displayProfiles.length]
: null;
const nextProfile =
displayProfiles.length > 1
? displayProfiles[(currentIndex + 1) % displayProfiles.length]
: null;
const getProfileImages = (profile: any) => {
if (!profile) return [];
if (Array.isArray(profile.images) && profile.images.length > 0) {
return profile.images;
}
if (
Array.isArray(profile.profile_photos) &&
profile.profile_photos.length > 0
) {
return profile.profile_photos
.slice()
.sort((a: any, b: any) => (a.position || 0) - (b.position || 0))
.map((photo: any) =>
getStorageUrl('profile-photos', photo.storage_path)
)
.filter(Boolean);
}
return [
profile.image ||
profile.avatar ||
getStorageUrl('avatars', profile.avatar_path) ||
'',
].filter(Boolean);
};
const getProfileImage = (profile: any) => getProfileImages(profile)[0] || '';
const commitSwipe = async (action: SwipeAction) => {
if (displayProfiles.length === 0) return;
if (previewMode) {
setCurrentIndex((prev) => (prev + 1) % displayProfiles.length);
return;
}
const targetUserId = profiles[currentIndex]?.id;
if (!targetUserId) return;
try {
const { data, error } = await supabase.functions.invoke('handle-swipe', {
body: { targetUserId, action },
});
if (error) throw error;
if (data?.matched && action === 'like') {
setShowHearts(true);
setTimeout(() => setShowHearts(false), 1250);
}
setCurrentIndex((prev) => prev + 1);
} catch (err) {
console.error('Swipe error:', err);
}
};
const animateSwipe = (action: SwipeAction) => {
if (swiping || !currentProfile) return;
setSwiping(true);
setExitDirection(action);
setDrag((prev) => ({
x: action === 'like' ? EXIT_DISTANCE : -EXIT_DISTANCE,
y: prev?.y || -12,
startX: prev?.startX || 0,
startY: prev?.startY || 0,
active: false,
}));
if (action === 'like') {
setShowHearts(true);
setTimeout(() => setShowHearts(false), 1250);
}
window.setTimeout(async () => {
await commitSwipe(action);
setDrag(null);
setExitDirection(null);
setSwiping(false);
}, 280);
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (swiping || !currentProfile) return;
cardRef.current?.setPointerCapture(event.pointerId);
setDrag({
x: 0,
y: 0,
startX: event.clientX,
startY: event.clientY,
active: true,
});
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!drag?.active || swiping) return;
setDrag({
...drag,
x: event.clientX - drag.startX,
y: event.clientY - drag.startY,
});
};
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (!drag || swiping) return;
cardRef.current?.releasePointerCapture(event.pointerId);
const absX = Math.abs(drag.x);
const absY = Math.abs(drag.y);
if (absX < 8 && absY < 8) {
const isRightHalf = event.clientX > window.innerWidth / 2;
setDrag(null);
animateSwipe(isRightHalf ? 'like' : 'skip');
return;
}
if (drag.x > SWIPE_THRESHOLD) {
animateSwipe('like');
return;
}
if (drag.x < -SWIPE_THRESHOLD) {
animateSwipe('skip');
return;
}
setDrag({ ...drag, x: 0, y: 0, active: false });
window.setTimeout(() => setDrag(null), 180);
};
const dragX = drag?.x || 0;
const dragY = drag?.y || 0;
const rotate = Math.max(-12, Math.min(12, dragX / 22));
const transition = drag?.active
? 'none'
: 'transform 280ms cubic-bezier(.2,.9,.22,1), opacity 220ms ease';
const topTransform = `translate3d(${dragX}px, ${dragY}px, 0) rotate(${rotate}deg)`;
const name = currentProfile?.full_name || currentProfile?.name || 'Nearby';
const age = calculateAge(currentProfile?.birth_date, currentProfile?.age);
const distance =
currentProfile?.distance || currentProfile?.travelTime || '3 min';
const interests =
currentProfile?.interests || currentProfile?.userProfile?.interests || [];
const currentImages = getProfileImages(currentProfile);
const currentImage = currentImages[0] || getProfileImage(currentProfile);
const topProgressBars =
currentImages.length > 0 ? currentImages : [currentImage];
return (
<IonPage>
<IonContent
fullscreen
style={{ '--background': '#000000' }}
scrollY={false}
>
<main
style={{
position: 'fixed',
inset: 0,
background: '#000000',
overflow: 'hidden',
touchAction: 'none',
}}
>
{loading && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
fontSize: 15,
}}
>
Finding people nearby
</div>
)}
{!loading && nextProfile && (
<article
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
right: 0,
top: 'calc(var(--ion-safe-area-top, 0px) + 6px)',
bottom: 'calc(76px + var(--ion-safe-area-bottom, 0px))',
borderRadius: 0,
overflow: 'hidden',
background: '#111111',
transform: 'scale(0.975)',
}}
>
<img
src={getProfileImage(nextProfile)}
alt="Next profile"
draggable={false}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
userSelect: 'none',
}}
/>
</article>
)}
{!loading && currentProfile && (
<article
ref={cardRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={{
position: 'absolute',
left: 0,
right: 0,
top: 'calc(var(--ion-safe-area-top, 0px) + 6px)',
bottom: 'calc(76px + var(--ion-safe-area-bottom, 0px))',
borderRadius: 0,
transform: topTransform,
transition,
opacity: exitDirection ? 0.9 : 1,
overflow: 'hidden',
background: '#111111',
cursor: drag?.active ? 'grabbing' : 'grab',
willChange: 'transform, opacity',
}}
>
<img
key={currentProfile.id}
src={currentImage}
alt={name}
draggable={false}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
userSelect: 'none',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
inset: 0,
background:
'linear-gradient(180deg, rgba(0,0,0,0.08) 0%, rgba(0,0,0,0.02) 45%, rgba(0,0,0,0.62) 73%, rgba(0,0,0,0.96) 100%)',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
top: 7,
left: 8,
right: 8,
display: 'grid',
gridTemplateColumns: `repeat(${topProgressBars.length}, 1fr)`,
gap: 4,
zIndex: 4,
}}
>
{topProgressBars.map((_, index) => (
<div
key={`media-progress-${index}`}
style={{
height: 3,
borderRadius: 999,
background:
index === 0
? 'rgba(255,255,255,0.94)'
: 'rgba(30,30,30,0.72)',
}}
/>
))}
</div>
<IonButton
fill="clear"
aria-label="Filter profiles"
style={{
position: 'absolute',
top: 24,
right: 14,
zIndex: 8,
'--color': '#fe3c72',
'--background': 'rgba(28, 28, 28, 0.76)',
'--border-radius': '999px',
width: 48,
height: 48,
}}
>
<IonIcon
icon={optionsOutline}
slot="icon-only"
style={{ fontSize: 27 }}
/>
</IonButton>
<section
style={{
position: 'absolute',
left: 14,
right: 14,
bottom: '34px',
zIndex: 4,
color: '#ffffff',
pointerEvents: 'none',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 14,
marginBottom: 22,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
textShadow: '0 2px 14px rgba(0,0,0,0.75)',
}}
>
<h1
style={{
margin: '0 0 4px',
fontSize: 30,
lineHeight: 1,
fontWeight: 900,
letterSpacing: -0.8,
}}
>
{name}, {age || ''}
</h1>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
color: '#ffffff',
fontSize: 18,
fontWeight: 800,
}}
>
<IonIcon
icon={walk}
style={{
color: '#ffffff',
flexShrink: 0,
fontSize: 25,
}}
/>
<span>{distance}</span>
</div>
</div>
<IonButton
aria-label="Send heart message"
onClick={(event) => {
event.stopPropagation();
animateSwipe('like');
}}
style={{
'--background': '#fe3c72',
'--color': '#111111',
'--border-radius': '999px',
width: 52,
height: 52,
marginTop: 2,
pointerEvents: 'auto',
flexShrink: 0,
}}
>
<IonIcon
icon={paperPlane}
slot="icon-only"
style={{ fontSize: 24 }}
/>
</IonButton>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 10,
fontSize: 21,
fontWeight: 900,
textShadow: '0 2px 12px rgba(0,0,0,0.8)',
}}
>
<IonIcon
icon={apps}
style={{ color: '#ffffff', fontSize: 32, flexShrink: 0 }}
/>
<span>Interests</span>
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
maxWidth: '100%',
}}
>
{interests
.slice(0, 5)
.map((interest: string, index: number) => (
<span
key={interest}
style={{
padding: '7px 13px',
borderRadius: 999,
background:
index === 0 || index === 3
? '#fe3c72'
: 'rgba(44, 53, 57, 0.78)',
color: '#ffffff',
fontSize: 16,
lineHeight: 1,
fontWeight: 650,
backdropFilter: 'blur(12px)',
maxWidth: '100%',
}}
>
{interest}
</span>
))}
</div>
</section>
</article>
)}
{!loading && displayProfiles.length === 0 && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 14,
color: '#ffffff',
padding: 24,
textAlign: 'center',
}}
>
<div style={{ fontSize: 18, fontWeight: 800 }}>
No nearby profiles right now.
</div>
<IonButton
onClick={fetchProfiles}
style={{
'--background': '#fe3c72',
'--border-radius': '999px',
}}
>
<IonIcon icon={refreshOutline} slot="start" />
Refresh
</IonButton>
</div>
)}
{!loading && currentProfile && (
<div
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 'calc(24px + var(--ion-safe-area-bottom, 0px))',
zIndex: 10,
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
padding: '0 54px',
pointerEvents: 'none',
}}
>
<IonButton
aria-label="Skip"
onClick={() => animateSwipe('skip')}
style={{
'--background': 'rgba(45,45,45,0.92)',
'--color': '#ff9f0a',
'--border-radius': '999px',
width: 70,
height: 70,
pointerEvents: 'auto',
}}
>
<IonIcon
icon={close}
slot="icon-only"
style={{ fontSize: 40, fontWeight: 900 }}
/>
</IonButton>
<IonButton
aria-label="Like"
onClick={() => animateSwipe('like')}
style={{
'--background': 'rgba(45,45,45,0.92)',
'--color': '#ff0000',
'--border-radius': '999px',
width: 70,
height: 70,
pointerEvents: 'auto',
}}
>
<IonIcon
icon={heart}
slot="icon-only"
style={{ fontSize: 42 }}
/>
</IonButton>
<IonButton
aria-label="Message"
onClick={() => animateSwipe('like')}
style={{
'--background': 'rgba(45,45,45,0.92)',
'--color': '#1683ff',
'--border-radius': '999px',
width: 70,
height: 70,
pointerEvents: 'auto',
}}
>
<IonIcon
icon={paperPlane}
slot="icon-only"
style={{ fontSize: 38 }}
/>
</IonButton>
</div>
)}
{showHearts && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<Lottie
animationData={heartsAnimation}
loop={false}
style={{ width: 310 }}
/>
</div>
)}
</main>
</IonContent>
</IonPage>
);
};
export default IcebreakerPage;
+165
View File
@@ -0,0 +1,165 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonInput,
IonItem,
IonButton,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const NewPasswordPage: React.FC = () => {
const history = useHistory();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
showError("Passwords don't match");
return;
}
if (password.length < 6) {
showError('Password must be at least 6 characters');
return;
}
setLoading(true);
try {
const { error } = await supabase.auth.updateUser({ password });
if (error) throw error;
history.push('/icebreaker');
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background': 'var(--color-bg)',
}}
>
<div style={{ padding: 'calc(var(--ion-safe-area-top) + 40px) 20px' }}>
<div style={{ marginBottom: '32px' }}>
<h1
style={{
fontSize: '28px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 8px',
}}
>
Create New Password
</h1>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: 0,
lineHeight: 1.5,
}}
>
Choose a strong password for your InSpot account.
</p>
</div>
<form
onSubmit={handleUpdate}
style={{
background: 'var(--color-surface)',
padding: '24px',
borderRadius: 'var(--radius-card)',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="New Password"
labelPlacement="floating"
type="password"
value={password}
onIonInput={(e) => setPassword(e.detail.value!)}
required
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="Confirm Password"
labelPlacement="floating"
type="password"
value={confirmPassword}
onIonInput={(e) => setConfirmPassword(e.detail.value!)}
required
/>
</IonItem>
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
}}
>
{error}
</div>
)}
<IonButton
type="submit"
expand="block"
disabled={loading}
style={{
'--border-radius': 'var(--radius-pill)',
height: '48px',
marginTop: '8px',
}}
>
{loading ? <IonSpinner name="crescent" /> : 'Update Password'}
</IonButton>
</form>
</div>
</IonContent>
</IonPage>
);
};
export default NewPasswordPage;
-229
View File
@@ -1,229 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonSpinner,
useIonViewWillEnter,
IonRefresher,
IonRefresherContent,
IonButtons,
IonButton,
IonIcon,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { NewsItem } from '../types';
import { addOutline, newspaperOutline } from 'ionicons/icons';
const News: React.FC = () => {
const history = useHistory();
const { profile } = useAuth();
const [news, setNews] = useState<NewsItem[]>([]);
const [loading, setLoading] = useState(true);
const fetchNews = async () => {
try {
const { data, error } = await supabase
.from('news')
.select('*')
.order('created_at', { ascending: false });
if (error) throw error;
setNews(data || []);
} catch (err) {
console.error('Error fetching news:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
fetchNews();
});
const doRefresh = async (event: any) => {
await fetchNews();
event.detail.complete();
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonTitle
style={{ fontWeight: '700', fontSize: '24px', color: '#1f2937' }}
>
News
</IonTitle>
{profile?.role === 'admin' && (
<IonButtons slot="end">
<IonButton onClick={() => history.push('/admin/news/new')}>
<IonIcon icon={addOutline} style={{ color: '#2f6b4f' }} />
</IonButton>
</IonButtons>
)}
</IonToolbar>
</IonHeader>
<IonContent style={{ '--background': '#f5f7f4' }}>
<IonRefresher slot="fixed" onIonRefresh={doRefresh}>
<IonRefresherContent />
</IonRefresher>
<div style={{ padding: '0 20px 20px' }}>
{loading ? (
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: '40px',
}}
>
<IonSpinner name="crescent" />
</div>
) : news.length === 0 ? (
<div
style={{
textAlign: 'center',
padding: '60px 20px',
color: '#6b7280',
}}
>
<IonIcon
icon={newspaperOutline}
style={{
fontSize: '48px',
color: '#cbd5e1',
marginBottom: '16px',
}}
/>
<p style={{ margin: 0, fontSize: '16px', fontWeight: '500' }}>
No news yet.
</p>
{profile?.role === 'admin' && (
<button
onClick={() => history.push('/admin/news/new')}
style={{
background: '#2f6b4f',
color: '#fff',
border: 'none',
borderRadius: '999px',
padding: '10px 20px',
fontWeight: '600',
marginTop: '20px',
cursor: 'pointer',
}}
>
Create Post
</button>
)}
</div>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
paddingTop: '8px',
}}
>
{news.map((item) => (
<div
key={item.id}
onClick={() => history.push(`/news/${item.id}`)}
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '16px',
display: 'flex',
gap: '16px',
cursor: 'pointer',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
alignItems: 'center',
}}
>
{item.image_url ? (
<img
src={item.image_url}
alt=""
style={{
width: '80px',
height: '80px',
borderRadius: '16px',
objectFit: 'cover',
flexShrink: 0,
background: '#f1f5f9',
}}
/>
) : (
<div
style={{
width: '80px',
height: '80px',
borderRadius: '16px',
background: 'rgba(47, 107, 79, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={newspaperOutline}
style={{ color: '#2f6b4f', fontSize: '28px' }}
/>
</div>
)}
<div
style={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
<div
style={{
fontSize: '12px',
fontWeight: '600',
color: '#9ca3af',
}}
>
{new Date(item.created_at).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
<div
style={{
fontSize: '16px',
fontWeight: '700',
color: '#1f2937',
lineHeight: '1.3',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{item.title}
</div>
</div>
</div>
))}
</div>
)}
</div>
</IonContent>
</IonPage>
);
};
export default News;
-187
View File
@@ -1,187 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonButtons,
IonButton,
IonIcon,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { NewsItem } from '../types';
import { chevronBackOutline, createOutline } from 'ionicons/icons';
const NewsDetail: React.FC = () => {
const history = useHistory();
const { id } = useParams<{ id: string }>();
const { profile } = useAuth();
const [news, setNews] = useState<NewsItem | null>(null);
const [loading, setLoading] = useState(true);
useIonViewWillEnter(() => {
fetchNewsItem();
});
const fetchNewsItem = async () => {
try {
const { data, error } = await supabase
.from('news')
.select('*')
.eq('id', id)
.single();
if (error) throw error;
setNews(data);
} catch (err) {
console.error('Error fetching news:', err);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonButtons slot="start">
<IonButton onClick={() => history.goBack()}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent style={{ '--background': '#f5f7f4' }}>
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: '40px',
}}
>
<IonSpinner name="crescent" />
</div>
</IonContent>
</IonPage>
);
}
if (!news) {
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonButtons slot="start">
<IonButton onClick={() => history.goBack()}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent style={{ '--background': '#f5f7f4' }}>
<div
style={{ textAlign: 'center', marginTop: '60px', color: '#6b7280' }}
>
News item not found.
</div>
</IonContent>
</IonPage>
);
}
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonButtons slot="start">
<IonButton onClick={() => history.goBack()}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
{profile?.role === 'admin' && (
<IonButtons slot="end">
<IonButton onClick={() => history.push(`/admin/news/${news.id}`)}>
<IonIcon
icon={createOutline}
slot="icon-only"
style={{ color: '#2f6b4f' }}
/>
</IonButton>
</IonButtons>
)}
</IonToolbar>
</IonHeader>
<IonContent style={{ '--background': '#f5f7f4' }}>
<div
style={{
padding: '0 20px 40px',
maxWidth: '600px',
margin: '0 auto',
}}
>
{news.image_url && (
<div style={{ marginBottom: '24px' }}>
<img
src={news.image_url}
alt=""
style={{
width: '100%',
height: '240px',
objectFit: 'cover',
borderRadius: '24px',
background: '#eef2ec',
}}
/>
</div>
)}
<div
style={{
fontSize: '14px',
fontWeight: '600',
color: '#2f6b4f',
marginBottom: '12px',
}}
>
{new Date(news.created_at).toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</div>
<h1
style={{
margin: '0 0 24px',
fontSize: '28px',
fontWeight: '800',
color: '#1f2937',
lineHeight: '1.2',
}}
>
{news.title}
</h1>
<div
style={{
fontSize: '16px',
color: '#4b5563',
lineHeight: '1.6',
whiteSpace: 'pre-wrap',
}}
>
{news.content}
</div>
</div>
</IonContent>
</IonPage>
);
};
export default NewsDetail;
+242
View File
@@ -0,0 +1,242 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import {
chevronBackOutline,
notificationsOutline,
heart,
chatbubble,
people,
flash,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { formatTime } from '../utils/format';
import EmptyState from '../components/EmptyState';
import { previewNotifications } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
import Lottie from 'lottie-react';
import notificationsAnimation from '../assets/images/InSpotNotifications.json';
const NotificationsPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const previewMode = isPreviewMode();
const [notifications, setNotifications] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const fetchNotifications = async () => {
if (previewMode) {
setNotifications(previewNotifications);
setLoading(false);
return;
}
if (!user) return;
setLoading(true);
try {
const { data, error } = await supabase
.from('notifications')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false });
if (error) throw error;
setNotifications(data || []);
// Mark as read after a short delay
if (data && data.some((n) => !n.read_at)) {
setTimeout(async () => {
await supabase
.from('notifications')
.update({ read_at: new Date().toISOString() })
.eq('user_id', user.id)
.is('read_at', null);
}, 2000);
}
} catch (err: any) {
console.error('Fetch notifications error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchNotifications();
});
const getIcon = (type: string) => {
switch (type) {
case 'reel_like':
return heart;
case 'reel_comment':
return chatbubble;
case 'match':
return people;
case 'message':
return chatbubble;
default:
return flash;
}
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
<IonButtons slot="start">
<IonButton
onClick={() => history.goBack()}
style={{ '--color': 'var(--color-text-primary)' }}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle>Notifications</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
{previewMode && (
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '12px 20px 0',
}}
>
<Lottie
animationData={notificationsAnimation}
loop
style={{ width: 120 }}
/>
</div>
)}
<div style={{ padding: '16px 20px' }}>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<IonSpinner name="crescent" color="primary" />
</div>
) : notifications.length === 0 ? (
<EmptyState
icon={notificationsOutline}
title="No notifications yet"
message="When you get likes, matches, or messages, they'll show up here."
ctaText="Go discover"
onCtaClick={() => history.push('/icebreaker')}
/>
) : (
<div
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
>
{notifications.map((n) => (
<div
key={n.id}
onClick={() => {
if (previewMode && !n.read_at) {
setNotifications((prev) =>
prev.map((item) =>
item.id === n.id
? { ...item, read_at: new Date().toISOString() }
: item
)
);
}
if (n.deep_link) history.push(n.deep_link);
}}
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
padding: '16px',
display: 'flex',
alignItems: 'center',
gap: '12px',
boxShadow: 'var(--shadow-card)',
opacity: n.read_at ? 0.7 : 1,
position: 'relative',
}}
>
<div
style={{
background: 'var(--color-accent-soft)',
width: '44px',
height: '44px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={getIcon(n.type)}
style={{
color: 'var(--ion-color-primary)',
fontSize: '20px',
}}
/>
</div>
<div style={{ flex: 1 }}>
<div
style={{
fontSize: '15px',
fontWeight: 700,
color: 'var(--color-text-primary)',
marginBottom: '2px',
}}
>
{n.title}
</div>
<div
style={{
fontSize: '13px',
color: 'var(--color-text-secondary)',
lineHeight: 1.4,
}}
>
{n.body}
</div>
<div
style={{
fontSize: '11px',
color: 'var(--color-text-tertiary)',
marginTop: '4px',
fontWeight: 600,
}}
>
{formatTime(n.created_at)}
</div>
</div>
{!n.read_at && (
<div
style={{
width: '8px',
height: '8px',
background: 'var(--ion-color-primary)',
borderRadius: '50%',
}}
/>
)}
</div>
))}
</div>
)}
</div>
</IonContent>
</IonPage>
);
};
export default NotificationsPage;
+318
View File
@@ -0,0 +1,318 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonInput,
IonItem,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonSelect,
IonSelectOption,
IonTextarea,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import Avatar from '../components/Avatar';
const OnboardingProfilePage: React.FC = () => {
const history = useHistory();
const { user, refreshProfile } = useAuth();
const [fullName, setFullName] = useState('');
const [birthDate, setBirthDate] = useState('');
const [bio, setBio] = useState('');
const [gender, setGender] = useState('');
const [relationshipGoal, setRelationshipGoal] = useState('');
const [avatarPath, setAvatarPath] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
if (!user) history.replace('/auth');
});
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !user) return;
setUploading(true);
try {
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Math.random()}.${fileExt}`;
const filePath = `avatars/${fileName}`;
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(filePath, file);
if (uploadError) throw uploadError;
setAvatarPath(filePath);
} catch (err: any) {
showError(err.message);
} finally {
setUploading(false);
}
};
const handleComplete = async () => {
if (!fullName || !birthDate || !gender || !relationshipGoal || !user) {
showError('Please fill in all required fields');
return;
}
setLoading(true);
try {
const { error } = await supabase.from('profiles').upsert({
id: user.id,
full_name: fullName,
birth_date: birthDate,
bio,
gender,
relationship_goal: relationshipGoal,
avatar_path: avatarPath,
onboarding_complete: true,
});
if (error) throw error;
await refreshProfile();
history.push('/icebreaker');
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
const goals = [
{ label: 'Long-term partner', value: 'long_term' },
{ label: 'Long-term, but short-term OK', value: 'long_term_short_ok' },
{ label: 'Short-term, but long-term OK', value: 'short_term_long_ok' },
{ label: 'Short-term fun', value: 'short_term' },
{ label: 'New friends', value: 'friends' },
{ label: 'Still figuring it out', value: 'figuring_it_out' },
];
return (
<IonPage>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
<div
style={{
padding:
'calc(var(--ion-safe-area-top) + 40px) 20px calc(var(--ion-safe-area-bottom) + 40px)',
}}
>
<div style={{ marginBottom: '32px', textAlign: 'center' }}>
<h1
style={{
fontSize: '28px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 8px',
}}
>
Create your profile
</h1>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: 0,
}}
>
Let others know who you are.
</p>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBottom: '32px',
}}
>
<div style={{ position: 'relative' }}>
<Avatar
src={avatarPath}
name={fullName}
size={120}
style={{ border: '4px solid #ffffff' }}
/>
<label
style={{
position: 'absolute',
bottom: 0,
right: 0,
background: 'var(--ion-color-primary)',
color: '#ffffff',
width: '36px',
height: '36px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
border: '3px solid #ffffff',
}}
>
{uploading ? <IonSpinner name="crescent" /> : '+'}
<input
type="file"
accept="image/*"
onChange={handleFileUpload}
hidden
disabled={uploading}
/>
</label>
</div>
</div>
<div
style={{
background: 'var(--color-surface)',
padding: '24px',
borderRadius: 'var(--radius-card)',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="Full Name"
labelPlacement="floating"
value={fullName}
onIonInput={(e) => setFullName(e.detail.value!)}
required
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonInput
label="Birth Date"
labelPlacement="floating"
type="date"
value={birthDate}
onIonInput={(e) => setBirthDate(e.detail.value!)}
required
/>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="Gender"
labelPlacement="floating"
value={gender}
onIonChange={(e) => setGender(e.detail.value)}
interface="action-sheet"
>
<IonSelectOption value="woman">Woman</IonSelectOption>
<IonSelectOption value="man">Man</IonSelectOption>
<IonSelectOption value="nonbinary">Non-binary</IonSelectOption>
<IonSelectOption value="other">Other</IonSelectOption>
<IonSelectOption value="prefer_not_to_say">
Prefer not to say
</IonSelectOption>
</IonSelect>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonSelect
label="What are you looking for?"
labelPlacement="floating"
value={relationshipGoal}
onIonChange={(e) => setRelationshipGoal(e.detail.value)}
interface="action-sheet"
>
{goals.map((g) => (
<IonSelectOption key={g.value} value={g.value}>
{g.label}
</IonSelectOption>
))}
</IonSelect>
</IonItem>
<IonItem
lines="none"
style={{
'--background': 'var(--color-bg)',
'--border-radius': 'var(--radius-control)',
}}
>
<IonTextarea
label="Bio"
labelPlacement="floating"
value={bio}
onIonInput={(e) => setBio(e.detail.value!)}
placeholder="Write something about yourself..."
rows={4}
/>
</IonItem>
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
}}
>
{error}
</div>
)}
<IonButton
expand="block"
disabled={loading || uploading}
onClick={handleComplete}
style={{
'--border-radius': 'var(--radius-pill)',
height: '48px',
marginTop: '8px',
}}
>
{loading ? <IonSpinner name="crescent" /> : 'Complete Profile'}
</IonButton>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default OnboardingProfilePage;
+300
View File
@@ -0,0 +1,300 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import {
chevronBackOutline,
checkmarkCircle,
cardOutline,
infiniteOutline,
eyeOutline,
heartOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { formatCurrency } from '../utils/format';
import { createCheckout, pollPaymentStatus } from '../utils/payments';
const PremiumPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<
'idle' | 'opening' | 'pending' | 'paid' | 'failed'
>('idle');
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const handleSubscribe = async () => {
setLoading(true);
setStatus('opening');
try {
const { checkoutUrl, paymentId } = await createCheckout(9.99, 'premium');
// Phase 1 stub behavior: simulate payment by following stub return
window.open(checkoutUrl, '_blank');
setStatus('pending');
const result = await pollPaymentStatus(paymentId);
if (result === 'paid') {
setStatus('paid');
setTimeout(() => history.goBack(), 2000);
} else {
setStatus('failed');
}
} catch (err) {
console.error('Payment error:', err);
setStatus('failed');
} finally {
setLoading(false);
}
};
const benefits = [
{
icon: infiniteOutline,
title: 'Unlimited Swipes',
desc: 'Swipe as much as you want.',
},
{
icon: eyeOutline,
title: 'See Who Likes You',
desc: 'Check out your admirers instantly.',
},
{
icon: heartOutline,
title: '5 Super Likes / Day',
desc: 'Stand out from the crowd.',
},
{
icon: checkmarkCircle,
title: 'Verified Badge',
desc: 'Get more trust and matches.',
},
];
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': 'var(--color-bg)' }}>
<IonButtons slot="start">
<IonButton
onClick={() => history.goBack()}
style={{ '--color': 'var(--color-text-primary)' }}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle>InSpot Premium</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
<div style={{ padding: '24px 20px' }}>
{/* Pricing Card */}
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
padding: '32px 24px',
textAlign: 'center',
boxShadow: 'var(--shadow-card)',
marginBottom: '32px',
border: '2px solid var(--ion-color-primary)',
}}
>
<div
style={{
fontSize: '15px',
fontWeight: 800,
color: 'var(--ion-color-primary)',
marginBottom: '8px',
textTransform: 'uppercase',
}}
>
Full Access
</div>
<div
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'center',
gap: '4px',
marginBottom: '8px',
}}
>
<span
style={{
fontSize: '40px',
fontWeight: 800,
color: 'var(--color-text-primary)',
}}
>
$9.99
</span>
<span
style={{
fontSize: '15px',
color: 'var(--color-text-tertiary)',
fontWeight: 600,
}}
>
/ month
</span>
</div>
<p
style={{
fontSize: '13px',
color: 'var(--color-text-secondary)',
margin: 0,
}}
>
Cancel anytime. No hidden fees.
</p>
</div>
<h3
style={{
fontSize: '18px',
fontWeight: 800,
color: 'var(--color-text-primary)',
marginBottom: '20px',
}}
>
What's included
</h3>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '20px',
marginBottom: '40px',
}}
>
{benefits.map((b, i) => (
<div
key={i}
style={{ display: 'flex', alignItems: 'center', gap: '16px' }}
>
<div
style={{
background: 'var(--color-accent-soft)',
width: '44px',
height: '44px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={b.icon}
style={{
color: 'var(--ion-color-primary)',
fontSize: '22px',
}}
/>
</div>
<div>
<div
style={{
fontSize: '15px',
fontWeight: 700,
color: 'var(--color-text-primary)',
}}
>
{b.title}
</div>
<div
style={{
fontSize: '13px',
color: 'var(--color-text-secondary)',
}}
>
{b.desc}
</div>
</div>
</div>
))}
</div>
{status === 'paid' && (
<div
style={{
background: 'rgba(22, 163, 74, 0.08)',
color: 'var(--ion-color-success)',
padding: '16px',
borderRadius: 'var(--radius-card)',
textAlign: 'center',
marginBottom: '24px',
fontWeight: 700,
}}
>
Payment successful! Welcome to Premium.
</div>
)}
{status === 'failed' && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '16px',
borderRadius: 'var(--radius-card)',
textAlign: 'center',
marginBottom: '24px',
fontWeight: 700,
}}
>
Payment failed or cancelled. Please try again.
</div>
)}
</div>
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: '#ffffff',
padding: '16px 20px calc(16px + var(--ion-safe-area-bottom))',
borderTop: '1px solid rgba(76, 5, 25, 0.05)',
}}
>
<IonButton
expand="block"
disabled={loading || status === 'paid'}
onClick={handleSubscribe}
style={{
'--border-radius': 'var(--radius-pill)',
height: '52px',
fontSize: '16px',
fontWeight: 700,
}}
>
{loading ? <IonSpinner name="crescent" /> : 'Get Premium Now'}
</IonButton>
</div>
</IonContent>
</IonPage>
);
};
export default PremiumPage;
-521
View File
@@ -1,521 +0,0 @@
import React, { useRef, useState } from 'react';
import { IonPage, IonContent, IonIcon, IonSpinner } from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import {
cameraOutline,
personOutline,
chevronForwardOutline,
settingsOutline,
helpCircleOutline,
} from 'ionicons/icons';
const Profile: React.FC = () => {
const [signingOut, setSigningOut] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [error, setError] = useState<string | null>(null);
const { user, profile, refreshProfile } = useAuth();
const history = useHistory();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const handleSignOut = async () => {
setSigningOut(true);
setError(null);
const { error: signOutError } = await supabase.auth.signOut();
if (signOutError) {
setError(signOutError.message);
setSigningOut(false);
return;
}
history.replace('/auth');
};
const goToCreateEvent = () => {
history.push('/admin/events/new');
};
const initials = profile?.full_name
? profile.full_name
.split(' ')
.map((n) => n[0])
.join('')
.substring(0, 2)
.toUpperCase()
: 'U';
const isAdmin = profile?.role === 'admin';
const avatarUrl = profile?.avatar_url
? `${supabase.storage.from('avatars').getPublicUrl(profile.avatar_url).data.publicUrl}?t=${Date.now()}`
: null;
const showError = (message: string) => {
setError(message);
setTimeout(() => setError(null), 4000);
};
const handleAvatarSelect = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0];
if (!file || !user) {
return;
}
setUploadingAvatar(true);
setError(null);
const extension = file.name.split('.').pop()?.toLowerCase() || 'jpg';
const filePath = `${user.id}/avatar-${Date.now()}.${extension}`;
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(filePath, file);
if (uploadError) {
showError(uploadError.message);
setUploadingAvatar(false);
event.target.value = '';
return;
}
const { error: profileError } = await supabase
.from('profiles')
.update({ avatar_url: filePath, updated_at: new Date().toISOString() })
.eq('id', user.id);
if (profileError) {
showError(profileError.message);
setUploadingAvatar(false);
event.target.value = '';
return;
}
await refreshProfile();
setUploadingAvatar(false);
event.target.value = '';
};
return (
<IonPage style={{ background: '#f5f7f4' }}>
<IonContent
style={{
'--background': '#f5f7f4',
'--padding-start': '0px',
'--padding-end': '0px',
'--padding-top': '0px',
'--padding-bottom': '24px',
}}
>
{/* Top Title Row */}
<div
style={{
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 8px',
margin: '0',
background: 'transparent',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
<h1
style={{
margin: 0,
fontSize: '24px',
fontWeight: '700',
color: '#1f2937',
}}
>
Profile
</h1>
<p
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: '#6b7280',
}}
>
Manage your account
</p>
</div>
{/* Profile Summary Card */}
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '20px',
margin: '0 20px 12px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<div
style={{
position: 'relative',
width: '52px',
height: '52px',
flexShrink: 0,
}}
>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploadingAvatar}
style={{
width: '52px',
height: '52px',
borderRadius: '16px',
background: 'rgba(47, 107, 79, 0.12)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
fontWeight: '700',
color: '#2f6b4f',
flexShrink: 0,
border: 'none',
padding: 0,
overflow: 'hidden',
cursor: uploadingAvatar ? 'default' : 'pointer',
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt={profile?.full_name || 'Profile'}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
initials
)}
</button>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploadingAvatar}
style={{
position: 'absolute',
right: '-4px',
bottom: '-4px',
width: '24px',
height: '24px',
borderRadius: '12px',
border: 'none',
background: '#2f6b4f',
color: '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: uploadingAvatar ? 'default' : 'pointer',
padding: 0,
}}
>
{uploadingAvatar ? (
<IonSpinner
name="crescent"
color="light"
style={{ width: '14px', height: '14px' }}
/>
) : (
<IonIcon icon={cameraOutline} style={{ fontSize: '13px' }} />
)}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleAvatarSelect}
style={{ display: 'none' }}
/>
</div>
<div
style={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: '4px',
}}
>
<div
style={{
fontSize: '18px',
fontWeight: '700',
color: '#1f2937',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{profile?.full_name || 'Member'}
</div>
<div
style={{
fontSize: '13px',
fontWeight: '400',
color: '#6b7280',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{profile?.email || ''}
</div>
</div>
{profile?.ministry_group && (
<div
style={{
background: '#eef2ec',
color: '#4b5563',
borderRadius: '999px',
padding: '4px 10px',
fontSize: '12px',
fontWeight: '600',
}}
>
{profile.ministry_group}
</div>
)}
</div>
{/* Settings / Action List Card */}
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '8px 18px',
margin: '0 20px 12px',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)',
}}
>
<div
style={{
minHeight: '56px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '12px',
background: '#eef2ec',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={personOutline}
style={{ color: '#2f6b4f', fontSize: '18px' }}
/>
</div>
<div
style={{
flex: 1,
fontSize: '15px',
fontWeight: '600',
color: '#1f2937',
}}
>
Personal Info
</div>
<IonIcon
icon={chevronForwardOutline}
style={{ color: '#9aa3a0', fontSize: '18px' }}
/>
</div>
<div
style={{
minHeight: '56px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '12px',
background: '#eef2ec',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={settingsOutline}
style={{ color: '#2f6b4f', fontSize: '18px' }}
/>
</div>
<div
style={{
flex: 1,
fontSize: '15px',
fontWeight: '600',
color: '#1f2937',
}}
>
Account Settings
</div>
<IonIcon
icon={chevronForwardOutline}
style={{ color: '#9aa3a0', fontSize: '18px' }}
/>
</div>
<div
style={{
minHeight: '56px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '12px',
background: '#eef2ec',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={helpCircleOutline}
style={{ color: '#2f6b4f', fontSize: '18px' }}
/>
</div>
<div
style={{
flex: 1,
fontSize: '15px',
fontWeight: '600',
color: '#1f2937',
}}
>
Help & Support
</div>
<IonIcon
icon={chevronForwardOutline}
style={{ color: '#9aa3a0', fontSize: '18px' }}
/>
</div>
</div>
{/* Admin Shortcut Card */}
{isAdmin && (
<div
style={{
background: 'rgba(47, 107, 79, 0.08)',
borderRadius: '24px',
padding: '18px',
margin: '0 20px 12px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
<div
style={{ fontSize: '14px', fontWeight: '600', color: '#2f6b4f' }}
>
Admin Tools
</div>
<button
onClick={goToCreateEvent}
style={{
background: '#2f6b4f',
border: 'none',
borderRadius: '999px',
padding: '14px 16px',
fontSize: '14px',
fontWeight: '600',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
}}
>
Create New Event
</button>
<button
onClick={() => history.push('/admin/news/new')}
style={{
background: 'rgba(47, 107, 79, 0.1)',
border: 'none',
borderRadius: '999px',
padding: '14px 16px',
fontSize: '14px',
fontWeight: '600',
color: '#2f6b4f',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
}}
>
Manage News
</button>
</div>
)}
{/* Sign Out Button */}
<div style={{ margin: '0 20px 24px 20px' }}>
<button
onClick={handleSignOut}
disabled={signingOut}
style={{
background: '#a33b32',
border: 'none',
borderRadius: '999px',
padding: '16px',
fontSize: '15px',
fontWeight: '600',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
}}
>
{signingOut ? <IonSpinner name="dots" color="light" /> : 'Sign Out'}
</button>
</div>
{error && (
<div
style={{
margin: '0 20px',
padding: '12px',
background: 'rgba(190,60,60,0.1)',
borderRadius: '12px',
color: '#a33b32',
fontSize: '13px',
}}
>
{error}
</div>
)}
</IonContent>
</IonPage>
);
};
export default Profile;
+497
View File
@@ -0,0 +1,497 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonIcon,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonAlert,
IonItem,
IonLabel,
IonList,
} from '@ionic/react';
import {
settingsOutline,
notificationsOutline,
checkmarkCircle,
chevronForwardOutline,
cardOutline,
shieldCheckmarkOutline,
logOutOutline,
trashOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import Avatar from '../components/Avatar';
import { getStorageUrl } from '../utils/storage';
import { previewCurrentUser, previewReels } from '../utils/previewData';
import { isPreviewMode, disablePreviewMode } from '../utils/previewMode';
const ProfilePage: React.FC = () => {
const history = useHistory();
const { user, profile, signOut } = useAuth();
const previewMode = isPreviewMode();
const displayProfile = previewMode && !profile ? previewCurrentUser : profile;
const [loading, setLoading] = useState(false);
const [reels, setReels] = useState<any[]>([]);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const fetchData = async () => {
if (previewMode && !user) {
setReels(previewReels);
return;
}
if (!user) return;
const { data } = await supabase
.from('reels')
.select('id, media_path')
.eq('author_id', user.id)
.limit(6);
setReels(data || []);
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchData();
});
const handleDeleteAccount = async () => {
setLoading(true);
try {
const { error } = await supabase.functions.invoke('delete-account', {
body: { confirm: true },
});
if (error) throw error;
await signOut();
history.replace('/auth');
} catch (err: any) {
console.error('Delete error:', err);
} finally {
setLoading(false);
}
};
return (
<IonPage>
<IonContent fullscreen style={{ '--background': 'var(--color-bg)' }}>
<div
style={{
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 12px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h1
style={{
fontSize: '24px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: 0,
}}
>
Profile
</h1>
<div style={{ display: 'flex', gap: '12px' }}>
<IonButton
fill="clear"
onClick={() => history.push('/notifications')}
style={{
'--background': 'var(--color-surface)',
'--color': 'var(--color-text-primary)',
'--border-radius': 'var(--radius-control)',
width: '44px',
height: '44px',
margin: 0,
}}
>
<IonIcon
icon={notificationsOutline}
slot="icon-only"
style={{ fontSize: '20px' }}
/>
</IonButton>
<IonButton
fill="clear"
style={{
'--background': 'var(--color-surface)',
'--color': 'var(--color-text-primary)',
'--border-radius': 'var(--radius-control)',
width: '44px',
height: '44px',
margin: 0,
}}
>
<IonIcon
icon={settingsOutline}
slot="icon-only"
style={{ fontSize: '20px' }}
/>
</IonButton>
</div>
</div>
<div style={{ padding: '0 20px 24px' }}>
{/* Profile Card */}
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
padding: '24px',
textAlign: 'center',
boxShadow: 'var(--shadow-card)',
marginBottom: '24px',
}}
>
<div
style={{
position: 'relative',
display: 'inline-block',
marginBottom: '16px',
}}
>
<Avatar
src={displayProfile?.avatar_path}
name={displayProfile?.full_name}
size={96}
style={{ border: '4px solid var(--color-bg)' }}
/>
<div
style={{
position: 'absolute',
bottom: 0,
right: 0,
background: '#ffffff',
borderRadius: '50%',
padding: '2px',
}}
>
<IonIcon
icon={checkmarkCircle}
style={{
color: 'var(--ion-color-success)',
fontSize: '24px',
}}
/>
</div>
</div>
<h2
style={{
fontSize: '22px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 4px',
}}
>
{displayProfile?.full_name || 'InSpot User'}
</h2>
<div
style={{
display: 'inline-block',
background: 'var(--color-accent-soft)',
color: 'var(--ion-color-primary)',
padding: '4px 12px',
borderRadius: 'var(--radius-pill)',
fontSize: '12px',
fontWeight: 700,
marginBottom: '20px',
}}
>
Long-term partner
</div>
<IonButton
expand="block"
onClick={() => history.push('/profile/edit')}
style={{
'--border-radius': 'var(--radius-pill)',
height: '44px',
'--background': 'var(--color-surface-raised)',
'--color': 'var(--ion-color-primary)',
'--box-shadow': 'none',
}}
>
Edit Profile
</IonButton>
</div>
{/* Premium CTA */}
<div
onClick={() => history.push('/premium')}
style={{
background:
'linear-gradient(135deg, var(--ion-color-primary) 0%, var(--ion-color-primary-dark) 100%)',
borderRadius: 'var(--radius-card)',
padding: '20px',
color: '#ffffff',
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '24px',
cursor: 'pointer',
}}
>
<div
style={{
background: 'rgba(255,255,255,0.2)',
width: '48px',
height: '48px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon icon={cardOutline} style={{ fontSize: '24px' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '15px', fontWeight: 800 }}>
InSpot Premium
</div>
<div style={{ fontSize: '12px', opacity: 0.8 }}>
Get unlimited swipes and see who likes you.
</div>
</div>
<IonIcon
icon={chevronForwardOutline}
style={{ fontSize: '20px' }}
/>
</div>
{/* My Reels */}
<div style={{ marginBottom: '24px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px',
}}
>
<h3
style={{
fontSize: '18px',
fontWeight: 800,
margin: 0,
color: 'var(--color-text-primary)',
}}
>
My Reels
</h3>
<IonButton
fill="clear"
style={{
fontSize: '13px',
'--color': 'var(--ion-color-primary)',
fontWeight: 700,
}}
>
See all
</IonButton>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '10px',
}}
>
{reels.map((reel) => (
<div
key={reel.id}
style={{
paddingBottom: '133%',
background: 'var(--color-surface-raised)',
borderRadius: '12px',
position: 'relative',
overflow: 'hidden',
}}
>
<img
src={
reel.image ||
getStorageUrl('reels', reel.media_path) ||
''
}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
alt="Reel"
/>
</div>
))}
<div
onClick={() => history.push('/camera')}
style={{
paddingBottom: '133%',
background: 'var(--color-surface-raised)',
borderRadius: '12px',
position: 'relative',
border: '2px dashed var(--ion-color-primary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon
icon={trashOutline}
style={{
fontSize: '24px',
color: 'var(--ion-color-primary)',
transform: 'rotate(45deg)',
}}
/>
</div>
</div>
</div>
</div>
{/* Settings List */}
<IonList
lines="none"
style={{ background: 'transparent', padding: 0 }}
>
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-card)',
padding: '8px',
boxShadow: 'var(--shadow-card)',
}}
>
<IonItem button detail={false} onClick={() => {}}>
<div
slot="start"
style={{
background: 'var(--color-accent-soft)',
width: '36px',
height: '36px',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginRight: '12px',
}}
>
<IonIcon
icon={shieldCheckmarkOutline}
color="primary"
style={{ fontSize: '20px' }}
/>
</div>
<IonLabel style={{ fontSize: '15px', fontWeight: 600 }}>
Privacy & Safety
</IonLabel>
<IonIcon
icon={chevronForwardOutline}
slot="end"
style={{
fontSize: '16px',
color: 'var(--color-text-tertiary)',
}}
/>
</IonItem>
<IonItem
button
detail={false}
onClick={() => {
localStorage.removeItem('inspot-preview-mode');
signOut();
history.replace('/auth');
}}
>
<div
slot="start"
style={{
background: 'var(--color-bg)',
width: '36px',
height: '36px',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginRight: '12px',
}}
>
<IonIcon
icon={logOutOutline}
color="dark"
style={{ fontSize: '20px' }}
/>
</div>
<IonLabel style={{ fontSize: '15px', fontWeight: 600 }}>
Sign Out
</IonLabel>
</IonItem>
<IonItem
button
detail={false}
onClick={() => setShowDeleteAlert(true)}
style={{ '--color': 'var(--ion-color-danger)' }}
>
<div
slot="start"
style={{
background: 'rgba(220, 38, 38, 0.08)',
width: '36px',
height: '36px',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginRight: '12px',
}}
>
<IonIcon
icon={trashOutline}
color="danger"
style={{ fontSize: '20px' }}
/>
</div>
<IonLabel style={{ fontSize: '15px', fontWeight: 600 }}>
Delete Account
</IonLabel>
</IonItem>
</div>
</IonList>
</div>
<IonAlert
isOpen={showDeleteAlert}
onDidDismiss={() => setShowDeleteAlert(false)}
header="Delete Account"
message="Are you sure you want to permanently delete your account? This action cannot be undone."
buttons={[
{ text: 'Cancel', role: 'cancel' },
{
text: 'Delete',
role: 'destructive',
handler: handleDeleteAccount,
},
]}
/>
</IonContent>
</IonPage>
);
};
export default ProfilePage;
+481
View File
@@ -0,0 +1,481 @@
import React, { useState, useEffect, useRef } from 'react';
import {
IonPage,
IonContent,
IonIcon,
IonButton,
IonSpinner,
useIonViewWillEnter,
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonItem,
IonInput,
} from '@ionic/react';
import {
closeOutline,
heartOutline,
heart,
chatbubbleOutline,
shareSocialOutline,
logoWhatsapp,
pinOutline,
send,
} from 'ionicons/icons';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import { formatCompactNumber, formatDate } from '../utils/format';
import { getStorageUrl } from '../utils/storage';
import Avatar from '../components/Avatar';
import { previewPlaces, previewReels } from '../utils/previewData';
import { isPreviewMode } from '../utils/previewMode';
const ReelsPage: React.FC = () => {
const history = useHistory();
const { placeId } = useParams<{ placeId: string }>();
const { user } = useAuth();
const [reels, setReels] = useState<any[]>([]);
const [place, setPlace] = useState<any>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [loading, setLoading] = useState(true);
const [commentsOpen, setCommentsOpen] = useState(false);
const [commentDraft, setCommentDraft] = useState('');
const [comments, setComments] = useState<any[]>([]);
const [likedReels, setLikedReels] = useState<Set<string>>(new Set());
const previewMode = isPreviewMode();
const fetchReels = async () => {
setLoading(true);
if (previewMode) {
setPlace(previewPlaces.find((p) => p.id === placeId) || previewPlaces[0]);
setReels(previewReels);
setLoading(false);
return;
}
try {
const { data: placeData } = await supabase
.from('places')
.select('*')
.eq('id', placeId)
.single();
setPlace(placeData);
const { data, error } = await supabase
.from('reels')
.select(
`
*,
author:profiles(id, full_name, avatar_path)
`
)
.eq('place_id', placeId)
.order('created_at', { ascending: false });
if (error) throw error;
setReels(data || []);
if (user) {
const { data: likes } = await supabase
.from('reel_likes')
.select('reel_id')
.eq('user_id', user.id);
setLikedReels(new Set(likes?.map((l) => l.reel_id) || []));
}
} catch (err: any) {
console.error('Fetch reels error:', err);
} finally {
setLoading(false);
}
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
fetchReels();
});
const handleLike = async (reelId: string) => {
if (!user && !previewMode) return;
const isLiked = likedReels.has(reelId);
// Optimistic update
const newLiked = new Set(likedReels);
if (isLiked) newLiked.delete(reelId);
else newLiked.add(reelId);
setLikedReels(newLiked);
if (previewMode) return;
try {
if (isLiked) {
await supabase
.from('reel_likes')
.delete()
.eq('reel_id', reelId)
.eq('user_id', user.id);
} else {
await supabase
.from('reel_likes')
.insert({ reel_id: reelId, user_id: user.id });
}
} catch (err) {
console.error('Like error:', err);
}
};
const currentReel = reels[activeIndex];
return (
<IonPage>
<IonContent fullscreen style={{ '--background': '#000000' }}>
<div
style={{
position: 'absolute',
top: 'calc(14px + var(--ion-safe-area-top))',
left: '20px',
zIndex: 10,
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<IonButton
fill="clear"
onClick={() => history.goBack()}
style={{
'--background': 'rgba(0,0,0,0.3)',
'--color': '#ffffff',
'--border-radius': '50%',
width: '40px',
height: '40px',
margin: 0,
}}
>
<IonIcon icon={closeOutline} slot="icon-only" />
</IonButton>
<span style={{ color: '#ffffff', fontWeight: 800, fontSize: '18px' }}>
Reels
</span>
</div>
{loading ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
background: '#000000',
}}
>
<IonSpinner name="crescent" color="primary" />
</div>
) : reels.length === 0 ? (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#ffffff',
padding: '20px',
textAlign: 'center',
}}
>
<p>No reels found for this place.</p>
<IonButton
expand="block"
fill="outline"
color="light"
onClick={() => history.push('/camera')}
>
Add the first reel
</IonButton>
</div>
) : (
<div
onClick={() =>
reels.length > 1 &&
setActiveIndex((prev) => (prev + 1) % reels.length)
}
style={{ height: '100%', width: '100%', position: 'relative' }}
>
{/* Media Area */}
<div style={{ width: '100%', height: '100%' }}>
{currentReel.media_type === 'video' ? (
<video
src={
currentReel.image ||
getStorageUrl('reels', currentReel.media_path) ||
''
}
autoPlay
loop
muted
playsInline
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<img
src={
currentReel.image ||
getStorageUrl('reels', currentReel.media_path) ||
''
}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
alt="Reel content"
/>
)}
</div>
{/* Action Rail */}
<div
style={{
position: 'absolute',
right: '12px',
bottom: '150px',
display: 'flex',
flexDirection: 'column',
gap: '20px',
alignItems: 'center',
}}
>
<div
onClick={(event) => {
event.stopPropagation();
handleLike(currentReel.id);
}}
style={{ textAlign: 'center' }}
>
<IonIcon
icon={likedReels.has(currentReel.id) ? heart : heartOutline}
style={{
fontSize: '32px',
color: likedReels.has(currentReel.id)
? 'var(--ion-color-primary)'
: '#ffffff',
}}
/>
<div
style={{
color: '#ffffff',
fontSize: '12px',
fontWeight: 700,
marginTop: '4px',
}}
>
{formatCompactNumber(currentReel.likes_count || 0)}
</div>
</div>
<div
onClick={(event) => {
event.stopPropagation();
setCommentsOpen(true);
}}
style={{ textAlign: 'center' }}
>
<IonIcon
icon={chatbubbleOutline}
style={{ fontSize: '30px', color: '#ffffff' }}
/>
<div
style={{
color: '#ffffff',
fontSize: '12px',
fontWeight: 700,
marginTop: '4px',
}}
>
{formatCompactNumber(currentReel.comments_count || 0)}
</div>
</div>
<IonIcon
icon={shareSocialOutline}
style={{ fontSize: '28px', color: '#ffffff' }}
/>
<IonIcon
icon={logoWhatsapp}
style={{ fontSize: '28px', color: '#ffffff' }}
/>
</div>
{/* Caption Overlay */}
<div
style={{
position: 'absolute',
bottom: 'calc(32px + var(--ion-safe-area-bottom))',
left: '20px',
right: '88px',
color: '#ffffff',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '16px',
}}
>
<div
style={{
background: 'var(--ion-color-primary)',
width: '36px',
height: '36px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon
icon={pinOutline}
style={{ fontSize: '18px', color: '#ffffff' }}
/>
</div>
<div>
<div style={{ fontSize: '13px', fontWeight: 800 }}>
{place?.name}
</div>
<div style={{ fontSize: '11px', opacity: 0.7 }}>
{formatDate(currentReel.created_at)}
</div>
</div>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
marginBottom: '8px',
}}
>
<Avatar
src={currentReel.author.avatar_path}
name={currentReel.author.full_name}
size={42}
/>
<span style={{ fontSize: '15px', fontWeight: 700 }}>
{currentReel.author.full_name}
</span>
</div>
<p
style={{
fontSize: '14px',
margin: 0,
opacity: 0.9,
lineHeight: 1.4,
}}
>
{currentReel.caption}
</p>
</div>
{/* Simple Next/Prev Controls for Phase 1 */}
{reels.length > 1 && (
<div
style={{
position: 'absolute',
bottom: '100px',
right: '12px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<IonButton
size="small"
fill="clear"
disabled={activeIndex === 0}
onClick={(event) => {
event.stopPropagation();
setActiveIndex((prev) => prev - 1);
}}
style={{ '--color': '#ffffff' }}
>
Prev
</IonButton>
<IonButton
size="small"
fill="clear"
disabled={activeIndex === reels.length - 1}
onClick={(event) => {
event.stopPropagation();
setActiveIndex((prev) => prev + 1);
}}
style={{ '--color': '#ffffff' }}
>
Next
</IonButton>
</div>
)}
</div>
)}
{/* Comments Sheet */}
<IonModal
isOpen={commentsOpen}
onDidDismiss={() => setCommentsOpen(false)}
initialBreakpoint={0.5}
breakpoints={[0, 0.5, 0.9]}
>
<IonHeader>
<IonToolbar>
<IonTitle>Comments</IonTitle>
<IonButtons slot="end">
<IonButton onClick={() => setCommentsOpen(false)}>
Close
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div
style={{
textAlign: 'center',
color: 'var(--color-text-secondary)',
padding: '20px',
}}
>
No comments yet. Be the first to comment!
</div>
{/* Input area fixed at bottom of modal content */}
<div
style={{
position: 'sticky',
bottom: 0,
background: '#ffffff',
padding: '12px 0',
}}
>
<div
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
>
<IonInput
value={commentDraft}
onIonInput={(e) => setCommentDraft(e.detail.value!)}
placeholder="Add a comment..."
style={{
background: 'var(--color-bg)',
'--border-radius': 'var(--radius-pill)',
'--padding-start': '16px',
}}
/>
<IonButton fill="clear" disabled={!commentDraft.trim()}>
<IonIcon icon={send} slot="icon-only" color="primary" />
</IonButton>
</div>
</div>
</IonContent>
</IonModal>
</IonContent>
</IonPage>
);
};
export default ReelsPage;
-250
View File
@@ -1,250 +0,0 @@
import React, { useState } from 'react';
import {
IonPage,
IonContent,
IonHeader,
IonToolbar,
IonTitle,
IonSpinner,
IonSelect,
IonSelectOption,
IonIcon,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { personAddOutline } from 'ionicons/icons';
const SetupProfile: React.FC = () => {
const [fullName, setFullName] = useState('');
const [ministryGroup, setMinistryGroup] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const history = useHistory();
const { user, refreshProfile } = useAuth();
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleProfileSetup = async (e: React.FormEvent) => {
e.preventDefault();
if (!fullName.trim()) {
showError('Please enter your name');
return;
}
if (!user) {
showError('No user session found. Please log in again.');
return;
}
setLoading(true);
setError(null);
const { error: insertError } = await supabase.from('profiles').insert({
id: user.id,
email: user.email ?? '',
full_name: fullName.trim(),
role: 'member',
ministry_group: ministryGroup || null,
});
if (insertError) {
showError(insertError.message);
setLoading(false);
return;
}
await refreshProfile();
history.replace('/home');
setLoading(false);
};
return (
<IonPage style={{ background: '#f5f7f4' }}>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f5f7f4' }}>
<IonTitle
style={{ fontSize: '18px', fontWeight: '700', color: '#1f2937' }}
>
Setup Profile
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent
style={{
'--background': '#f5f7f4',
'--padding-start': '20px',
'--padding-end': '20px',
'--padding-top': '16px',
'--padding-bottom': '20px',
}}
>
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '24px',
margin: '0 0 16px 0',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
<div
style={{
background: 'rgba(47, 107, 79, 0.12)',
borderRadius: '12px',
width: '44px',
height: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={personAddOutline}
style={{ fontSize: '20px', color: '#2f6b4f' }}
/>
</div>
<h2
style={{
margin: 0,
fontSize: '20px',
fontWeight: '700',
color: '#1f2937',
}}
>
Welcome to EAGLES ASSEMBLY
</h2>
<p
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: '#6b7280',
}}
>
Please let us know who you are so we can personalize your
experience.
</p>
</div>
<form
onSubmit={handleProfileSetup}
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '24px',
margin: '0 0 16px 0',
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<input
type="text"
placeholder="Full Name (e.g. John Doe)"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
style={{
background: '#eef2ec',
border: 'none',
borderRadius: '12px',
padding: '0 14px',
height: '48px',
fontSize: '15px',
color: '#1f2937',
outline: 'none',
}}
/>
<div
style={{
background: '#eef2ec',
borderRadius: '12px',
padding: '0 14px',
height: '48px',
display: 'flex',
alignItems: 'center',
}}
>
<IonSelect
value={ministryGroup}
placeholder="Select Ministry Group (Optional)"
onIonChange={(e) => setMinistryGroup(e.detail.value)}
interface="popover"
style={{
width: '100%',
color: '#1f2937',
fontSize: '15px',
'--placeholder-color': '#9aa3a0',
'--placeholder-opacity': '1',
}}
>
<IonSelectOption value="Youth">Youth</IonSelectOption>
<IonSelectOption value="Choir">Choir</IonSelectOption>
<IonSelectOption value="Ushering">Ushering</IonSelectOption>
<IonSelectOption value="Media">Media</IonSelectOption>
</IonSelect>
</div>
<button
type="submit"
disabled={loading}
style={{
background: '#2f6b4f',
border: 'none',
borderRadius: '999px',
padding: '16px',
fontSize: '15px',
fontWeight: '600',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: '8px',
}}
>
{loading ? (
<IonSpinner name="dots" color="light" />
) : (
'Complete Setup'
)}
</button>
</form>
{error && (
<div
style={{
background: 'rgba(190, 60, 60, 0.10)',
borderRadius: '12px',
padding: '12px 14px',
margin: '0',
}}
>
<p
style={{
margin: 0,
fontSize: '13px',
fontWeight: '500',
color: '#a33b32',
lineHeight: '1.4',
}}
>
{error}
</p>
</div>
)}
</IonContent>
</IonPage>
);
};
export default SetupProfile;
-296
View File
@@ -1,296 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { IonPage, IonContent, IonSpinner, IonIcon } from '@ionic/react';
import { useHistory, useLocation } from 'react-router-dom';
import { supabase } from '../supabase';
import OtpInputRow from '../components/OtpInputRow';
import { checkmarkCircleOutline } from 'ionicons/icons';
const VerifyEmail: React.FC = () => {
const [digits, setDigits] = useState<string[]>(Array(6).fill(''));
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cooldown, setCooldown] = useState(0);
const [verified, setVerified] = useState(false);
const history = useHistory();
const location = useLocation<{ email?: string; resent?: boolean }>();
const email = location.state?.email || '';
const resent = location.state?.resent || false;
const inputRefs = [
useRef<HTMLIonInputElement>(null),
useRef<HTMLIonInputElement>(null),
useRef<HTMLIonInputElement>(null),
useRef<HTMLIonInputElement>(null),
useRef<HTMLIonInputElement>(null),
useRef<HTMLIonInputElement>(null),
];
useEffect(() => {
if (!email) {
history.replace('/auth');
}
if (resent) {
setCooldown(60);
}
}, [email, resent, history]);
useEffect(() => {
if (cooldown > 0) {
const timer = setTimeout(() => setCooldown(cooldown - 1), 1000);
return () => clearTimeout(timer);
}
}, [cooldown]);
const handleDigitChange = (index: number, value: string) => {
const sanitized = value.replace(/[^0-9]/g, '').slice(-1);
const newDigits = [...digits];
newDigits[index] = sanitized;
setDigits(newDigits);
if (sanitized && index < 5) {
inputRefs[index + 1].current?.setFocus();
}
};
const handleKeyDown = (
index: number,
e: React.KeyboardEvent<HTMLIonInputElement>
) => {
if (e.key === 'Backspace') {
if (!digits[index] && index > 0) {
inputRefs[index - 1].current?.setFocus();
}
}
};
const handleVerifyOtp = async () => {
const code = digits.join('');
if (code.length !== 6) {
setError('Please enter all 6 digits');
return;
}
setLoading(true);
setError(null);
const { error } = await supabase.auth.verifyOtp({
email,
token: code,
type: 'email',
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
setVerified(true);
setLoading(false);
};
const handleResend = async () => {
if (cooldown > 0) return;
setLoading(true);
setError(null);
const { error } = await supabase.auth.resend({ type: 'signup', email });
if (error) {
setError(error.message);
setLoading(false);
return;
}
setCooldown(60);
setLoading(false);
};
const goBackToAuth = () => {
history.replace('/auth');
};
return (
<IonPage style={{ background: '#f6f1e8' }}>
<IonContent
style={{
'--background': 'linear-gradient(180deg, #f9f5ee 0%, #f3ede3 100%)',
'--padding-start': '0px',
'--padding-end': '0px',
'--padding-top': '0px',
'--padding-bottom': '0px',
}}
>
<div
style={{
padding:
'calc(var(--ion-safe-area-top, 0px) + 18px) 20px calc(var(--ion-safe-area-bottom, 0px) + 28px)',
minHeight: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
background: 'transparent',
borderRadius: '0px',
padding: '0px',
margin: '0 0 24px 0',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<h1
style={{
margin: 0,
fontSize: '24px',
fontWeight: '700',
color: '#1f2937',
}}
>
Check your email
</h1>
<p
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: '#6b7280',
}}
>
We sent a 6-digit code to {email}.
</p>
</div>
{!verified ? (
<>
<OtpInputRow
digits={digits}
onChange={handleDigitChange}
onKeyDown={handleKeyDown}
inputRefs={inputRefs}
/>
<button
onClick={handleVerifyOtp}
disabled={loading}
style={{
background: '#2f6b4f',
border: 'none',
borderRadius: '999px',
padding: '16px',
fontSize: '15px',
fontWeight: '600',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 0 16px 0',
}}
>
{loading ? <IonSpinner name="dots" color="light" /> : 'Verify'}
</button>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button
onClick={handleResend}
disabled={cooldown > 0 || loading}
style={{
background: 'transparent',
border: 'none',
borderRadius: '999px',
padding: '10px 14px',
fontSize: '14px',
fontWeight: '600',
color: cooldown > 0 ? '#9aa3a0' : '#2f6b4f',
cursor: cooldown > 0 ? 'default' : 'pointer',
}}
>
{cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'}
</button>
</div>
{error && (
<div
style={{
background: 'rgba(190, 60, 60, 0.10)',
borderRadius: '12px',
padding: '12px 14px',
margin: '16px 0 0 0',
}}
>
<p
style={{
margin: 0,
fontSize: '13px',
fontWeight: '500',
color: '#a33b32',
lineHeight: '1.4',
}}
>
{error}
</p>
</div>
)}
</>
) : (
<div
style={{
background: '#ffffff',
borderRadius: '24px',
padding: '24px',
margin: '24px 0 0 0',
display: 'flex',
flexDirection: 'column',
gap: '12px',
alignItems: 'center',
textAlign: 'center',
}}
>
<IonIcon
icon={checkmarkCircleOutline}
style={{ fontSize: '36px', color: '#2f6b4f' }}
/>
<h2
style={{
margin: 0,
fontSize: '18px',
fontWeight: '700',
color: '#1f2937',
}}
>
Email confirmed
</h2>
<p
style={{
margin: 0,
fontSize: '14px',
fontWeight: '400',
color: '#6b7280',
}}
>
You can now sign in to your account.
</p>
<button
onClick={goBackToAuth}
style={{
background: '#2f6b4f',
border: 'none',
borderRadius: '999px',
padding: '16px',
fontSize: '15px',
fontWeight: '600',
color: '#ffffff',
cursor: 'pointer',
width: '100%',
marginTop: '12px',
}}
>
Back to Sign In
</button>
</div>
)}
</div>
</IonContent>
</IonPage>
);
};
export default VerifyEmail;
+200
View File
@@ -0,0 +1,200 @@
import React, { useState, useEffect } from 'react';
import {
IonPage,
IonContent,
IonButton,
IonSpinner,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory, useLocation } from 'react-router-dom';
import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import OtpInput from '../components/OtpInput';
const VerifyEmailPage: React.FC = () => {
const history = useHistory();
const location = useLocation<{
email: string;
type: 'signup' | 'recovery';
}>();
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cooldown, setCooldown] = useState(60);
const email = location.state?.email;
const type = location.state?.type || 'signup';
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
if (!email) {
history.replace('/auth');
}
});
useEffect(() => {
if (cooldown > 0) {
const timer = setTimeout(() => setCooldown(cooldown - 1), 1000);
return () => clearTimeout(timer);
}
}, [cooldown]);
const showError = (msg: string) => {
setError(msg);
setTimeout(() => setError(null), 4000);
};
const handleVerify = async () => {
if (code.length !== 6) return;
setLoading(true);
try {
let result;
if (type === 'recovery') {
const params: any = {
email,
token: code,
type: 'recovery',
};
result = await (supabase.auth as any).verifyOtp(params);
} else {
result = await supabase.auth.verifyOtp({
email,
token: code,
type: 'email',
});
}
if (result.error) throw result.error;
if (type === 'recovery') {
history.push('/new-password', { email });
} else {
history.push('/onboarding/profile');
}
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
const handleResend = async () => {
if (cooldown > 0) return;
setLoading(true);
try {
const resendParams: any = {
type: type === 'signup' ? 'email' : 'recovery',
email,
};
const { error } = await supabase.auth.resend(resendParams);
if (error) throw error;
setCooldown(60);
} catch (err: any) {
showError(err.message);
} finally {
setLoading(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background': 'var(--color-bg)',
}}
>
<div style={{ padding: 'calc(var(--ion-safe-area-top) + 40px) 20px' }}>
<div style={{ marginBottom: '32px' }}>
<h1
style={{
fontSize: '28px',
fontWeight: 800,
color: 'var(--color-text-primary)',
margin: '0 0 8px',
}}
>
Check your email
</h1>
<p
style={{
fontSize: '15px',
color: 'var(--color-text-secondary)',
margin: 0,
lineHeight: 1.5,
}}
>
We've sent a 6-digit verification code to{' '}
<span
style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}
>
{email}
</span>
.
</p>
</div>
<div
style={{
background: 'var(--color-surface)',
padding: '24px',
borderRadius: 'var(--radius-card)',
textAlign: 'center',
}}
>
<OtpInput value={code} onChange={setCode} disabled={loading} />
{error && (
<div
style={{
background: 'rgba(220, 38, 38, 0.08)',
color: 'var(--ion-color-danger)',
padding: '12px',
borderRadius: 'var(--radius-control)',
fontSize: '13px',
marginBottom: '20px',
}}
>
{error}
</div>
)}
<IonButton
expand="block"
disabled={loading || code.length !== 6}
onClick={handleVerify}
style={{
'--border-radius': 'var(--radius-pill)',
height: '48px',
}}
>
{loading ? <IonSpinner name="crescent" /> : 'Verify'}
</IonButton>
<button
onClick={handleResend}
disabled={loading || cooldown > 0}
style={{
background: 'none',
border: 'none',
color:
cooldown > 0
? 'var(--color-text-tertiary)'
: 'var(--ion-color-primary)',
fontSize: '14px',
fontWeight: 600,
marginTop: '20px',
cursor: cooldown > 0 ? 'default' : 'pointer',
}}
>
{cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'}
</button>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default VerifyEmailPage;
+2 -2
View File
@@ -2,6 +2,6 @@ import { createClient } from '@supabase/supabase-js';
import type { Database } from './database.types'; import type { Database } from './database.types';
export const supabase = createClient<Database>( export const supabase = createClient<Database>(
'https://kxejpmyjfdrbaupauexc.supabase.co', 'https://yunudlmsairdxxlhfyci.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt4ZWpwbXlqZmRyYmF1cGF1ZXhjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODM2OTkzMzksImV4cCI6MjA5OTI3NTMzOX0.FteANsPY4x605R26bxC6sR4iiYbw_nwG4zrcDYEH83Y', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1bnVkbG1zYWlyZHh4bGhmeWNpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODExNDk2OTUsImV4cCI6MjA5NjcyNTY5NX0.WRYh_r5YuruaGrgfAR6Tg_CrSI1CPyYgpvrJQVTbyUE',
); );
+190 -58
View File
@@ -1,73 +1,205 @@
/* Ionic Variables and Color Palette */ /* InSpot exact theme: high-contrast black/white with Tinder Pink accents */
:root { :root {
--ion-color-primary: #7b2d26; --ion-color-primary: #fe3c72;
--ion-color-primary-rgb: 123, 45, 38; --ion-color-primary-rgb: 254, 60, 114;
--ion-color-primary-contrast: #ffffff; --ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255, 255, 255; --ion-color-primary-contrast-rgb: 255, 255, 255;
--ion-color-primary-shade: #6c2721; --ion-color-primary-shade: #e03564;
--ion-color-primary-tint: #883f3a; --ion-color-primary-tint: #fe507f;
--ion-color-secondary: #b07a3a; --ion-color-secondary: #ef4a75;
--ion-color-secondary-rgb: 176, 122, 58; --ion-color-secondary-rgb: 239, 74, 117;
--ion-color-secondary-contrast: #ffffff; --ion-color-secondary-contrast: #ffffff;
--ion-color-secondary-contrast-rgb: 255, 255, 255; --ion-color-secondary-contrast-rgb: 255, 255, 255;
--ion-color-secondary-shade: #9b6b33; --ion-color-secondary-shade: #d24167;
--ion-color-secondary-tint: #b8894e; --ion-color-secondary-tint: #f15c83;
--ion-background-color: #f8f4ef; --ion-color-tertiary: #9e9e9e;
--color-bg: #f8f4ef; --ion-color-tertiary-rgb: 158, 158, 158;
--color-surface: #ffffff; --ion-color-tertiary-contrast: #000000;
--color-surface-raised: #f2ece4; --ion-color-tertiary-contrast-rgb: 0, 0, 0;
--color-brand: #7b2d26; --ion-color-tertiary-shade: #8b8b8b;
--color-brand-soft: rgba(123, 45, 38, 0.1); --ion-color-tertiary-tint: #a8a8a8;
--color-brand-medium: rgba(123, 45, 38, 0.18);
--ion-text-color: #2f221d; --ion-color-success: #22c55e;
--ion-text-color-rgb: 47, 34, 29; --ion-color-success-rgb: 34, 197, 94;
--color-text-primary: #2f221d; --ion-color-success-contrast: #ffffff;
--color-text-secondary: rgba(47, 34, 29, 0.64); --ion-color-success-contrast-rgb: 255, 255, 255;
--color-text-tertiary: rgba(47, 34, 29, 0.42); --ion-color-success-shade: #1eae53;
--color-border: rgba(47, 34, 29, 0.08); --ion-color-success-tint: #38cb6e;
--ion-color-success: #7b2d26; --ion-color-warning: #ffcc00;
--ion-color-warning: #d29a3f; --ion-color-warning-rgb: 255, 204, 0;
--ion-color-danger: #a33b32; --ion-color-warning-contrast: #000000;
--ion-color-medium: #8e7d73; --ion-color-warning-contrast-rgb: 0, 0, 0;
--ion-color-light: #f8f4ef; --ion-color-warning-shade: #e0b400;
--ion-color-warning-tint: #ffd11a;
--ion-color-danger: #ff4444;
--ion-color-danger-rgb: 255, 68, 68;
--ion-color-danger-contrast: #ffffff;
--ion-color-danger-contrast-rgb: 255, 255, 255;
--ion-color-danger-shade: #e03c3c;
--ion-color-danger-tint: #ff5757;
--ion-color-dark: #000000;
--ion-color-dark-rgb: 0, 0, 0;
--ion-color-dark-contrast: #ffffff;
--ion-color-dark-contrast-rgb: 255, 255, 255;
--ion-color-dark-shade: #000000;
--ion-color-dark-tint: #1a1a1a;
--ion-color-medium: #9e9e9e;
--ion-color-medium-rgb: 158, 158, 158;
--ion-color-medium-contrast: #000000;
--ion-color-medium-contrast-rgb: 0, 0, 0;
--ion-color-medium-shade: #8b8b8b;
--ion-color-medium-tint: #a8a8a8;
--ion-color-light: #ffffff;
--ion-color-light-rgb: 255, 255, 255;
--ion-color-light-contrast: #424242;
--ion-color-light-contrast-rgb: 66, 66, 66;
--ion-color-light-shade: #e0e0e0;
--ion-color-light-tint: #ffffff;
--color-bg: #000000;
--color-bg-light: #ffffff;
--color-surface: rgba(44, 53, 57, 0.5);
--color-surface-solid: #151a1d;
--color-surface-raised: rgba(44, 53, 57, 0.72);
--color-input: rgba(44, 53, 57, 0.5);
--color-border: #333333;
--color-text-primary: #ffffff;
--color-text-secondary: #aaaaaa;
--color-text-tertiary: #9e9e9e;
--color-inactive: #9e9e9e;
--color-light-text-primary: #424242;
--color-light-text-secondary: #555555;
--color-light-text-tertiary: #888888;
--color-light-card: #dcdcdc;
--color-alert: #ff4444;
--color-alert-light: #ff0000;
--color-accent-soft: rgba(254, 60, 114, 0.18);
--color-accent-softer: rgba(254, 60, 114, 0.1);
--radius-pill: 999px;
--radius-card: 20px;
--radius-control: 10px;
--shadow-card: none;
} }
@media (prefers-color-scheme: dark) {
:root {
--ion-background-color: #201513;
--color-bg: #201513;
--color-surface: #2b1c19;
--color-surface-raised: #3a2723;
--color-brand: #c7865f;
--color-brand-soft: rgba(199, 134, 95, 0.14);
--color-brand-medium: rgba(199, 134, 95, 0.22);
--ion-text-color: #f5ebe3;
--ion-text-color-rgb: 245, 235, 227;
--color-text-primary: #f5ebe3;
--color-text-secondary: rgba(245, 235, 227, 0.68);
--color-text-tertiary: rgba(245, 235, 227, 0.44);
--ion-color-primary: #c7865f;
--ion-color-primary-rgb: 199, 134, 95;
--ion-color-primary-contrast: #201513;
--ion-color-primary-contrast-rgb: 32, 21, 19;
--ion-color-primary-shade: #b27754;
--ion-color-primary-tint: #cd9270;
}
}
html,
body { body {
background-color: var(--color-bg); background: #000000;
color: #ffffff;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
sans-serif;
} }
.page-shell { .ion-color-primary {
padding: calc(var(--ion-safe-area-top, 0px) + 18px) 20px --ion-color-base: var(--ion-color-primary);
calc(var(--ion-safe-area-bottom, 0px) + 28px); --ion-color-base-rgb: var(--ion-color-primary-rgb);
--ion-color-contrast: var(--ion-color-primary-contrast);
--ion-color-contrast-rgb: var(--ion-color-primary-contrast-rgb);
--ion-color-shade: var(--ion-color-primary-shade);
--ion-color-tint: var(--ion-color-primary-tint);
}
.ion-color-secondary {
--ion-color-base: var(--ion-color-secondary);
--ion-color-base-rgb: var(--ion-color-secondary-rgb);
--ion-color-contrast: var(--ion-color-secondary-contrast);
--ion-color-contrast-rgb: var(--ion-color-secondary-contrast-rgb);
--ion-color-shade: var(--ion-color-secondary-shade);
--ion-color-tint: var(--ion-color-secondary-tint);
}
ion-content {
--background: #000000;
--color: #ffffff;
}
ion-tab-bar {
--background: #101014;
--border: none;
height: calc(64px + var(--ion-safe-area-bottom));
padding-bottom: var(--ion-safe-area-bottom);
box-shadow: none;
}
ion-tab-button {
--background: #101014;
--color: #9e9e9e;
--color-selected: #fe3c72;
}
ion-tab-button ion-icon {
font-size: 24px;
}
ion-tab-button ion-label {
font-size: 10px;
font-weight: 600;
margin-top: 4px;
}
ion-header ion-toolbar {
--background: #000000;
--border-width: 0;
--color: #ffffff;
padding: 0 4px;
}
ion-header ion-title {
font-weight: 700;
font-size: 17px;
}
ion-item {
--background: transparent;
--border-color: #333333;
--color: #ffffff;
--highlight-color-focused: #fe3c72;
}
ion-list-header {
--color: #9e9e9e;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
ion-modal {
--background: #1b181f;
--border-radius: 20px 20px 0 0;
--color: #ffffff;
}
ion-modal::part(content) {
background: #1b181f;
border-radius: 20px 20px 0 0;
}
ion-searchbar {
--background: rgba(44, 53, 57, 0.5);
--border-radius: 999px;
--box-shadow: none;
--color: #ffffff;
--placeholder-color: #aaaaaa;
--icon-color: #ffffff;
--clear-button-color: #9e9e9e;
padding-left: 0;
padding-right: 0;
}
ion-button {
--background: #fe3c72;
--background-activated: #ef4a75;
--background-focused: #ef4a75;
--background-hover: #ef4a75;
--color: #ffffff;
--box-shadow: none;
text-transform: none;
} }

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