Author SHA1 Message Date
AppCakes c279e86750 build: 55712324-398e-4334-a254-fc30c84b2e47 2026-05-31 14:22:23 +00:00
76 changed files with 8629 additions and 7326 deletions
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@ android {
namespace = "io.ionic.starter"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "io.ionic.starter"
applicationId "com.habitmode.llemba.com"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
+1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">bringg</string>
<string name="title_activity_main">bringg</string>
<string name="app_name">app</string>
<string name="title_activity_main">app</string>
<string name="package_name">io.ionic.starter</string>
<string name="custom_url_scheme">io.ionic.starter</string>
</resources>
+3 -3
View File
@@ -1,13 +1,13 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.bringg.app',
appName: 'bringg',
appId: 'com.habitmode.llemba.com',
appName: 'HabitMode',
webDir: 'dist',
plugins: {
FirebaseAuthentication: {
skipNativeAuth: false,
providers: [],
providers: ["google.com", "apple.com"],
},
},
};
+33 -31
View File
@@ -55,6 +55,13 @@ workflows:
if [ -n "$CM_DEBUG_KEYSTORE" ]; then
echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12
fi
- name: Check native credentials
script: |
if [ -n "$VITE_GOOGLE_MAPS_KEY" ]; then
echo "VITE_GOOGLE_MAPS_KEY: set (length=${#VITE_GOOGLE_MAPS_KEY}, ends …${VITE_GOOGLE_MAPS_KEY: -4})"
else
echo "WARNING: VITE_GOOGLE_MAPS_KEY not set — Google Maps will not render"
fi
- name: Build debug APK
script: |
cd android
@@ -68,6 +75,21 @@ workflows:
else
./gradlew assembleDebug --no-daemon
fi
- name: Verify manifest substitution
script: |
MANIFEST="android/app/build/intermediates/merged_manifests/debug/AndroidManifest.xml"
if [ -f "$MANIFEST" ]; then
VAL=$(grep -A1 'geo.API_KEY' "$MANIFEST" | grep -o 'android:value="[^"]*"' | cut -d'"' -f2)
if [ -z "$VAL" ]; then
echo "WARNING: geo.API_KEY meta-data not found in merged manifest"
elif [ "$VAL" = '${googleMapsApiKey}' ]; then
echo "WARNING: geo.API_KEY placeholder was not substituted — key missing from build env"
else
echo "geo.API_KEY substituted OK (ends …${VAL: -4})"
fi
else
echo "Merged manifest not found at $MANIFEST"
fi
artifacts:
- android/app/build/outputs/apk/debug/app-debug.apk
@@ -239,36 +261,25 @@ workflows:
script: rm -f package-lock.json && npm install --legacy-peer-deps
- name: Build web assets
script: npm run build
# cap sync ios must run before Firebase config is written so that CapApp-SPM/Package.swift
# is regenerated with the correct plugin list first. The Firebase plist is written
# afterwards and a second sync re-runs to ensure everything is consistent.
# First sync: regenerates CapApp-SPM/Package.swift with the correct plugin list.
# Note: this project uses Capacitor SPM (no CocoaPods/Podfile), so cap sync replaces
# pod install — it regenerates CapApp-SPM/Package.swift and copies web assets.
- name: Capacitor sync
script: npx cap sync ios
# GoogleService-Info.plist is injected as a base64 env var. The Ruby snippet adds the
# file reference to the Xcode project if it isn't already there, so Xcode includes it
# in the app bundle at build time.
# Decode Firebase plist to disk so it is present during the second sync.
- name: Write Firebase config
script: |
if [ -n "$GOOGLE_SERVICE_INFO_PLIST" ]; then
echo "$GOOGLE_SERVICE_INFO_PLIST" | base64 --decode > ios/App/App/GoogleService-Info.plist
ruby -e "
require 'xcodeproj'
project = Xcodeproj::Project.open('ios/App/App.xcodeproj')
target = project.targets.find { |t| t.name == 'App' }
app_group = project.main_group['App']
unless app_group.files.find { |f| f.path == 'GoogleService-Info.plist' }
file_ref = app_group.new_reference('GoogleService-Info.plist')
target.resources_build_phase.add_file_reference(file_ref)
project.save
end
"
fi
# Second sync ensures CapApp-SPM/Package.swift reflects any plugin changes made during
# the Firebase config step and keeps the package graph in a consistent state.
# Second sync ensures CapApp-SPM/Package.swift is consistent after the plist write.
- name: Capacitor sync
script: npx cap sync ios
# Run configure_xcode.rb after both syncs so its xcodeproj changes (plist file
# reference + SPM product linking) are not overwritten by a subsequent cap sync.
# configure_xcode.rb is stamped fresh from template on every build push.
- name: Configure Xcode project
script: ruby configure_xcode.rb
- name: Set up signing
script: |
keychain initialize
@@ -337,24 +348,15 @@ workflows:
script: npm run build
- name: Capacitor sync
script: npx cap sync ios
- name: Write Firebase config
- name: Configure Xcode project
script: |
if [ -n "$GOOGLE_SERVICE_INFO_PLIST" ]; then
echo "$GOOGLE_SERVICE_INFO_PLIST" | base64 --decode > ios/App/App/GoogleService-Info.plist
ruby -e "
require 'xcodeproj'
project = Xcodeproj::Project.open('ios/App/App.xcodeproj')
target = project.targets.find { |t| t.name == 'App' }
app_group = project.main_group['App']
unless app_group.files.find { |f| f.path == 'GoogleService-Info.plist' }
file_ref = app_group.new_reference('GoogleService-Info.plist')
target.resources_build_phase.add_file_reference(file_ref)
project.save
end
"
fi
- name: Capacitor sync
script: npx cap sync ios
- name: Configure Xcode project
script: ruby configure_xcode.rb
- name: Set up signing
script: |
keychain initialize
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env ruby
# configure_xcode.rb — stamped fresh from template on every build (see gitea.service.ts)
#
# 1. Adds GoogleService-Info.plist to the Xcode target if present (xcodeproj gem)
# 2. Scans Swift sources for Firebase imports and injects matching products into
# CapApp-SPM/Package.swift — the correct approach for Capacitor SPM projects
# where all native linking goes through CapApp-SPM, not App.xcodeproj directly.
require 'xcodeproj'
PROJECT_PATH = 'ios/App/App.xcodeproj'
TARGET_NAME = 'App'
PLIST_SRC = 'ios/App/App/GoogleService-Info.plist'
CAP_APP_SPM = 'ios/App/CapApp-SPM/Package.swift'
# ── 1. GoogleService-Info.plist ───────────────────────────────────────────────
if File.exist?(PLIST_SRC)
project = Xcodeproj::Project.open(PROJECT_PATH)
target = project.targets.find { |t| t.name == TARGET_NAME }
if target
app_group = project.main_group['App']
unless app_group&.files&.find { |f| f.path == 'GoogleService-Info.plist' }
file_ref = app_group.new_reference('GoogleService-Info.plist')
target.resources_build_phase.add_file_reference(file_ref)
project.save
puts "Added GoogleService-Info.plist to target"
end
end
end
# ── 2. Scan Swift sources for Firebase imports ────────────────────────────────
swift_files = Dir['ios/App/App/**/*.swift']
firebase_imports = swift_files
.flat_map { |f| File.readlines(f) rescue [] }
.grep(/^\s*import\s+Firebase\w+/)
.map { |l| l.strip.split(/\s+/).last }
.uniq
.sort
if firebase_imports.empty?
puts "No Firebase imports found — skipping CapApp-SPM update"
exit 0
end
puts "Firebase imports detected: #{firebase_imports.join(', ')}"
unless File.exist?(CAP_APP_SPM)
warn "WARNING: #{CAP_APP_SPM} not found — was cap sync run?"
exit 0
end
# ── 3. Detect firebase-ios-sdk URL and version from node_modules ──────────────
# Read from the ACTUAL node_modules on this build machine (populated by npm install)
# so the version we inject matches what @capacitor-firebase/* already resolved.
firebase_url = nil
firebase_version = nil
Dir['node_modules/@capacitor-firebase/**/Package.swift',
'node_modules/@capacitor/push-notifications/**/Package.swift'].each do |f|
content = File.read(f) rescue next
url_m = content.match(/\.package\(url:\s*"(https:\/\/github\.com\/firebase\/firebase-ios-sdk[^"]*)"/)
ver_m = content.match(/firebase-ios-sdk[^)]+from:\s*"(\d+)\.(\d+)\.(\d+)"/)
next unless url_m && ver_m
firebase_url = url_m[1]
major = ver_m[1].to_i
firebase_version = "#{major}.0.0"
puts "Detected firebase-ios-sdk from #{f}: url=#{firebase_url} → using from: \"#{firebase_version}\""
break
end
# Fallback — should rarely be needed
unless firebase_url
firebase_url = 'https://github.com/firebase/firebase-ios-sdk.git'
firebase_version = '11.0.0'
puts "Could not detect firebase-ios-sdk version from node_modules — using fallback #{firebase_version}"
end
firebase_pkg = 'firebase-ios-sdk'
lines = File.read(CAP_APP_SPM).split("\n")
# ── 4. Inject firebase-ios-sdk as a direct package dependency if missing ──────
# Normalise URL check to handle both with and without .git suffix
firebase_url_base = firebase_url.sub(/\.git$/, '')
unless lines.any? { |l| l.include?(firebase_url_base) }
last_pkg_idx = lines.rindex { |l| l.include?('.package(') }
if last_pkg_idx
lines[last_pkg_idx] = lines[last_pkg_idx].rstrip
lines[last_pkg_idx] += ',' unless lines[last_pkg_idx].end_with?(',')
lines.insert(last_pkg_idx + 1,
" .package(url: \"#{firebase_url}\", from: \"#{firebase_version}\")")
puts "Added #{firebase_pkg} to CapApp-SPM package dependencies"
else
warn "WARNING: no .package( line found in #{CAP_APP_SPM} — cannot inject dependency"
end
end
# ── 5. Inject each detected product into the target dependencies if missing ───
firebase_imports.each do |product|
next if product == 'Firebase' # umbrella import — not a linkable SPM product
product_str = ".product(name: \"#{product}\", package: \"#{firebase_pkg}\")"
next if lines.any? { |l| l.include?(product_str) }
last_prod_idx = lines.rindex { |l| l.include?('.product(') }
if last_prod_idx
lines[last_prod_idx] = lines[last_prod_idx].rstrip
lines[last_prod_idx] += ',' unless lines[last_prod_idx].end_with?(',')
lines.insert(last_prod_idx + 1, " #{product_str}")
puts "Added #{product} to CapApp-SPM target dependencies"
else
warn "WARNING: no .product( line found in #{CAP_APP_SPM} — cannot inject #{product}"
end
end
File.write(CAP_APP_SPM, lines.join("\n"))
puts "CapApp-SPM/Package.swift updated"
+6 -2
View File
@@ -26,6 +26,7 @@
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
1A4EB3C21FED79650016851F /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
@@ -68,6 +69,7 @@
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
1A4EB3C21FED79650016851F /* App.entitlements */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
@@ -295,6 +297,7 @@
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
@@ -305,7 +308,7 @@
);
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.bringg.app;
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
@@ -317,6 +320,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
@@ -326,7 +330,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.bringg.app;
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
+6
View File
@@ -2,5 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>aps-environment</key>
<string>production</string>
</dict>
</plist>
+22 -23
View File
@@ -1,5 +1,7 @@
import UIKit
import Capacitor
import FirebaseCore
import FirebaseMessaging
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
@@ -7,43 +9,40 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
FirebaseApp.configure()
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}
func applicationWillResignActive(_ application: UIApplication) {}
func applicationDidEnterBackground(_ application: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}
func applicationDidEnterBackground(_ application: UIApplication) {}
func applicationWillEnterForeground(_ application: UIApplication) {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
}
func applicationWillEnterForeground(_ application: UIApplication) {}
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}
func applicationDidBecomeActive(_ application: UIApplication) {}
func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
func applicationWillTerminate(_ application: UIApplication) {}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
// Called when the app was launched with an activity, including Universal Links.
// Feel free to add additional processing here, but if you want the App API to support
// tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
Messaging.messaging().token { token, error in
if let error = error {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
} else if let token = token {
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: token)
}
}
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
}
}
+2 -4
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>bringg</string>
<string>HabitMode</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -50,11 +50,9 @@
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.bringg.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.bringg.app</string>
<string>com.googleusercontent.apps.1097913965629-pf48952i0uv9shgvie3dq11s18sd0t2p</string>
</array>
</dict>
</array>
+3404
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -11,7 +11,6 @@
},
"dependencies": {
"@capacitor-firebase/authentication": "^8.0.0",
"firebase": "^11.0.0",
"@capacitor/android": "8.3.4",
"@capacitor/app": "8.1.0",
"@capacitor/core": "8.3.4",
@@ -21,16 +20,17 @@
"@capacitor/keyboard": "8.0.3",
"@capacitor/push-notifications": "^8.1.1",
"@capacitor/status-bar": "8.0.2",
"@ionic/pwa-elements": "^3.0.0",
"@ionic/pwa-elements": "^3.4.0",
"@ionic/react": "^8.5.0",
"@ionic/react-router": "^8.5.0",
"@supabase/supabase-js": "^2.0.0",
"firebase": "^11.0.0",
"ionicons": "^7.4.0",
"katex": "^0.16.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.54.0",
"react-katex": "^3.0.1",
"@supabase/supabase-js": "^2.0.0",
"react-router": "^5.3.4",
"react-router-dom": "^5.3.4",
"zod": "^3.24.0",
+10 -29
View File
@@ -17,41 +17,22 @@ import '@ionic/react/css/display.css';
import './theme/variables.css';
import Home from './pages/Home';
import ProductDetail from './pages/ProductDetail';
import Cart from './pages/Cart';
import OrderConfirmation from './pages/OrderConfirmation';
import MyOrders from './pages/MyOrders';
import OrderDetail from './pages/OrderDetail';
import Account from './pages/Account';
import Auth from './pages/Auth';
import Profile from './pages/Profile';
import Addresses from './pages/Addresses';
import AdminOrders from './pages/AdminOrders';
import AdminOrderDetail from './pages/AdminOrderDetail';
import { AuthProvider } from './context/AuthContext';
import Stats from './pages/Stats';
import HabitDetail from './pages/HabitDetail';
const App: React.FC = () => (
const App = (): any => (
<IonApp>
<IonReactRouter>
<AuthProvider>
<IonRouterOutlet>
<Route path="/home" component={Home} exact />
<Route path="/auth" component={Auth} exact />
<Route path="/product/:id" component={ProductDetail} exact />
<Route path="/cart" component={Cart} exact />
<Route path="/orders" component={MyOrders} exact />
<Route path="/orders/:id" component={OrderDetail} exact />
<Route path="/profile" component={Profile} exact />
<Route path="/addresses" component={Addresses} exact />
<Route path="/admin/orders" component={AdminOrders} exact />
<Route path="/admin/orders/:id" component={AdminOrderDetail} exact />
<Route
path="/order-confirmation"
component={OrderConfirmation}
exact
/>
<Redirect exact from="/" to="/home" />
<Route exact path="/auth" component={Auth} />
<Route exact path="/home" component={Home} />
<Route exact path="/stats" component={Stats} />
<Route exact path="/habit/:id" component={HabitDetail} />
<Route exact path="/account" component={Account} />
<Route exact path="/" render={() => <Redirect to="/home" />} />
</IonRouterOutlet>
</AuthProvider>
</IonReactRouter>
</IonApp>
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 839 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 KiB

View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 689 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 697 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

-36
View File
@@ -1,36 +0,0 @@
import React from 'react';
import { Redirect, Route, RouteProps } from 'react-router-dom';
import { IonLoading } from '@ionic/react';
import { useAuth } from '../context/AuthContext';
type Props = RouteProps;
const AdminRoute: React.FC<Props> = ({ component: Component, ...rest }) => {
const { user, profile, isLoading } = useAuth();
if (!Component) return null;
return (
<Route
{...rest}
render={(props) => {
if (isLoading) {
return <IonLoading isOpen message="Loading..." />;
}
if (!user) {
return <Redirect to="/auth" />;
}
if (!profile?.is_admin) {
return <Redirect to="/home" />;
}
const RouteComponent = Component as React.ComponentType<any>;
return <RouteComponent {...props} />;
}}
/>
);
};
export default AdminRoute;
+77
View File
@@ -0,0 +1,77 @@
import React from 'react';
import { IonProgressBar } from '@ionic/react';
interface CustomProgressBarProps {
value: number; // 0 to 1
label?: string;
statusText?: string;
color?: string; // Hex, rgb, or css variable
showPercent?: boolean;
style?: React.CSSProperties;
}
export const CustomProgressBar: React.FC<CustomProgressBarProps> = ({
value,
label,
statusText,
color = 'var(--ion-color-primary)',
showPercent = true,
style,
}) => {
const percentage = Math.round(Math.min(Math.max(value, 0), 1) * 100);
return (
<div style={{ width: '100%', ...style }}>
{/* Label and Status */}
{(label || statusText || showPercent) && (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
fontSize: '0.8rem',
marginBottom: '6px',
fontWeight: '600',
}}
>
<span style={{ color: 'var(--ion-color-medium)' }}>
{label}{' '}
{statusText && (
<span style={{ fontWeight: 'normal', opacity: 0.85 }}>
• {statusText}
</span>
)}
</span>
{showPercent && (
<span
style={{
color: color,
fontVariantNumeric: 'tabular-nums',
fontFamily: 'ui-monospace, monospace',
fontWeight: '700',
}}
>
{percentage}%
</span>
)}
</div>
)}
{/* Progress Track */}
<IonProgressBar
value={value}
style={
{
height: '6px',
'--background': 'rgba(148, 163, 184, 0.1)',
'--progress-background': color,
borderRadius: '10px',
transition: 'value 0.3s ease',
} as React.CSSProperties
}
/>
</div>
);
};
export default CustomProgressBar;
+32
View File
@@ -0,0 +1,32 @@
import React from 'react';
import { IonButton, IonIcon } from '@ionic/react';
import { sparklesOutline } from 'ionicons/icons';
interface EmptyStateProps {
title: string;
message: string;
actionLabel: string;
onAction: () => void;
}
const EmptyState: React.FC<EmptyStateProps> = ({
title,
message,
actionLabel,
onAction,
}) => {
return (
<div className="empty-state-card home-empty-state-card">
<div className="empty-state-icon">
<IonIcon icon={sparklesOutline} />
</div>
<h2>{title}</h2>
<p>{message}</p>
<IonButton className="home-empty-state-button" onClick={onAction}>
{actionLabel}
</IonButton>
</div>
);
};
export default EmptyState;
+108
View File
@@ -0,0 +1,108 @@
import React from 'react';
import { IonButton, IonIcon, IonSpinner } from '@ionic/react';
import {
checkmarkOutline,
ellipseOutline,
flameOutline,
trashOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import type { Tables } from '../database.types';
interface HabitCardProps {
habit: Tables<'habits'>;
completedToday: boolean;
currentStreak: number;
completionRate: number;
onToggle: () => void;
onDelete: () => void;
busy: boolean;
}
const HabitCard: React.FC<HabitCardProps> = ({
habit,
completedToday,
currentStreak,
completionRate,
onToggle,
onDelete,
busy,
}) => {
const history = useHistory();
return (
<div
className={`habit-card habit-card--editorial ${completedToday ? 'is-complete' : ''}`}
role="button"
tabIndex={0}
onClick={() => history.push(`/habit/${habit.id}`)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
history.push(`/habit/${habit.id}`);
}
}}
aria-label={`View details for ${habit.name}`}
>
<button
type="button"
className={`habit-check-button ${completedToday ? 'is-complete' : ''}`}
onClick={(event) => {
event.stopPropagation();
onToggle();
}}
disabled={busy}
aria-label={
completedToday
? `Mark ${habit.name} as not done`
: `Mark ${habit.name} as done`
}
>
{busy ? (
<IonSpinner name="crescent" />
) : completedToday ? (
<IonIcon icon={checkmarkOutline} />
) : (
<IonIcon icon={ellipseOutline} />
)}
</button>
<div className="habit-card-main">
<div className="habit-copy">
<div className="habit-copy-topline">
<h3>{habit.name}</h3>
<span
className="habit-color-pill"
style={{ background: habit.color }}
/>
</div>
<p>
{habit.target_description ||
'Show up today and keep the chain alive.'}
</p>
<div className="habit-metrics-row">
<span>
<IonIcon icon={flameOutline} /> {currentStreak} streak
</span>
<span>{completionRate}% this week</span>
</div>
</div>
</div>
<IonButton
className="habit-delete-button"
fill="clear"
color="medium"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
disabled={busy}
>
<IonIcon icon={trashOutline} />
</IonButton>
</div>
);
};
export default HabitCard;
+153
View File
@@ -0,0 +1,153 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonInput,
IonItem,
IonLabel,
IonModal,
IonTextarea,
IonTitle,
IonToolbar,
} from '@ionic/react';
interface HabitFormModalProps {
isOpen: boolean;
onDismiss: () => void;
onSubmit: (values: {
name: string;
targetDescription: string;
color: string;
}) => Promise<void>;
initialValues?: {
name: string;
targetDescription: string;
color: string;
};
}
const COLORS = [
'#7c3aed',
'#0f766e',
'#ea580c',
'#dc2626',
'#2563eb',
'#059669',
];
const HabitFormModal: React.FC<HabitFormModalProps> = ({
isOpen,
onDismiss,
onSubmit,
initialValues,
}) => {
const isEditing = !!initialValues;
const [name, setName] = useState(initialValues?.name ?? '');
const [targetDescription, setTargetDescription] = useState(
initialValues?.targetDescription ?? ''
);
const [color, setColor] = useState(initialValues?.color ?? COLORS[0]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const canSubmit = useMemo(
() => name.trim().length > 0 && name.trim().length <= 80,
[name]
);
useEffect(() => {
if (isOpen) {
setName(initialValues?.name ?? '');
setTargetDescription(initialValues?.targetDescription ?? '');
setColor(initialValues?.color ?? COLORS[0]);
setError('');
}
}, [isOpen, initialValues]);
const handleSubmit = async () => {
setError('');
setSaving(true);
try {
await onSubmit({
name: name.trim(),
targetDescription: targetDescription.trim(),
color,
});
setName('');
setTargetDescription('');
setColor(COLORS[0]);
onDismiss();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to save habit.');
} finally {
setSaving(false);
}
};
return (
<IonModal isOpen={isOpen} onDidDismiss={onDismiss}>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonTitle>{isEditing ? 'Edit Habit' : 'New Habit'}</IonTitle>
<IonButtons slot="end">
<IonButton onClick={onDismiss}>Close</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div className="habit-form-grid">
<IonItem lines="none" className="auth-input-item">
<IonInput
label="Habit name"
labelPlacement="stacked"
value={name}
maxlength={80}
placeholder="Drink water"
onIonInput={(e) => setName(e.detail.value ?? '')}
/>
</IonItem>
<IonItem lines="none" className="auth-input-item">
<IonTextarea
label="Target or note"
labelPlacement="stacked"
value={targetDescription}
placeholder="8 glasses, 20 minutes, 30 pushups..."
autoGrow
onIonInput={(e) => setTargetDescription(e.detail.value ?? '')}
/>
</IonItem>
<div>
<IonLabel className="color-label">Color</IonLabel>
<div className="habit-color-row">
{COLORS.map((swatch) => (
<button
key={swatch}
type="button"
className={`habit-color-dot ${color === swatch ? 'selected' : ''}`}
style={{ background: swatch }}
onClick={() => setColor(swatch)}
/>
))}
</div>
</div>
{error ? <p className="auth-feedback error-text">{error}</p> : null}
<IonButton
expand="block"
onClick={handleSubmit}
disabled={!canSubmit || saving}
>
{saving ? 'Saving…' : isEditing ? 'Save Changes' : 'Create Habit'}
</IonButton>
</div>
</IonContent>
</IonModal>
);
};
export default HabitFormModal;
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import {
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonBackButton,
useIonViewWillEnter,
} from '@ionic/react';
import { setStatusBarStyle, Style } from '../utils/statusBar';
interface PageHeaderProps {
title: string;
showBackButton?: boolean;
defaultBackHref?: string;
endActions?: React.ReactNode;
style?: React.CSSProperties;
toolbarStyle?: React.CSSProperties;
}
export const PageHeader: React.FC<PageHeaderProps> = ({
title,
showBackButton = false,
defaultBackHref = '/home',
endActions,
style,
toolbarStyle,
}) => {
// Use Ionic lifecycle hook to set status bar style automatically for pages using PageHeader
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark); // Dark text/icons for light-colored headers
});
return (
<IonHeader className="ion-no-border" style={style}>
<IonToolbar
style={
{
'--background': '#ffffff',
'--color': 'var(--ion-color-dark)',
'--border-width': '0 0 1px 0',
'--border-color': 'rgba(0, 0, 0, 0.05)',
...toolbarStyle,
} as React.CSSProperties
}
>
{showBackButton && (
<IonButtons slot="start">
<IonBackButton defaultHref={defaultBackHref} />
</IonButtons>
)}
<IonTitle
style={{
fontWeight: '800',
fontSize: '1.1rem',
letterSpacing: '-0.3px',
}}
>
{title}
</IonTitle>
{endActions && <IonButtons slot="end">{endActions}</IonButtons>}
</IonToolbar>
</IonHeader>
);
};
export default PageHeader;
-101
View File
@@ -1,101 +0,0 @@
import React from 'react';
import {
IonButton,
IonIcon,
IonLabel,
IonModal,
IonRadio,
IonRadioGroup,
IonItem,
IonList,
} from '@ionic/react';
import { funnelOutline } from 'ionicons/icons';
export type SortOption = 'popular' | 'price-asc' | 'price-desc' | 'name-asc';
type Props = {
categories: string[];
selectedCategory: string;
setSelectedCategory: (value: string) => void;
sortOption: SortOption;
setSortOption: (value: SortOption) => void;
isOpen: boolean;
onClose: () => void;
};
const ProductFilters: React.FC<Props> = ({
categories,
selectedCategory,
setSelectedCategory,
sortOption,
setSortOption,
isOpen,
onClose,
}) => (
<>
<div className="filter-row">
<div className="filter-chip-row">
<IonButton
fill={selectedCategory === 'All' ? 'solid' : 'outline'}
size="small"
onClick={() => setSelectedCategory('All')}
>
All
</IonButton>
{categories.map((category) => (
<IonButton
key={category}
fill={selectedCategory === category ? 'solid' : 'outline'}
size="small"
onClick={() => setSelectedCategory(category)}
>
{category}
</IonButton>
))}
</div>
<IonButton fill="clear" className="filter-button" onClick={onClose}>
<IonIcon icon={funnelOutline} slot="start" />
Filters
</IonButton>
</div>
<IonModal
isOpen={isOpen}
onDidDismiss={onClose}
breakpoints={[0, 0.45]}
initialBreakpoint={0.45}
>
<div className="filter-modal ion-padding">
<h2>Sort products</h2>
<IonRadioGroup
value={sortOption}
onIonChange={(e) => setSortOption(e.detail.value)}
>
<IonList lines="none">
<IonItem>
<IonLabel>Most popular</IonLabel>
<IonRadio value="popular" />
</IonItem>
<IonItem>
<IonLabel>Price: low to high</IonLabel>
<IonRadio value="price-asc" />
</IonItem>
<IonItem>
<IonLabel>Price: high to low</IonLabel>
<IonRadio value="price-desc" />
</IonItem>
<IonItem>
<IonLabel>Name: A to Z</IonLabel>
<IonRadio value="name-asc" />
</IonItem>
</IonList>
</IonRadioGroup>
<IonButton expand="block" onClick={onClose}>
Done
</IonButton>
</div>
</IonModal>
</>
);
export default ProductFilters;
-32
View File
@@ -1,32 +0,0 @@
import React from 'react';
import { Redirect, Route, RouteProps } from 'react-router-dom';
import { IonLoading } from '@ionic/react';
import { useAuth } from '../context/AuthContext';
type Props = RouteProps;
const ProtectedRoute: React.FC<Props> = ({ component: Component, ...rest }) => {
const { user, isLoading } = useAuth();
if (!Component) return null;
return (
<Route
{...rest}
render={(props) => {
if (isLoading) {
return <IonLoading isOpen message="Loading..." />;
}
if (!user) {
return <Redirect to="/auth" />;
}
const RouteComponent = Component as React.ComponentType<any>;
return <RouteComponent {...props} />;
}}
/>
);
};
export default ProtectedRoute;
+80
View File
@@ -0,0 +1,80 @@
import React from 'react';
import { IonIcon } from '@ionic/react';
interface RevisionInstrumentCardProps {
title: string;
subtitle?: string;
icon: string;
iconColor: string;
iconBgColor: string;
onClick: () => void;
style?: React.CSSProperties;
}
export const RevisionInstrumentCard: React.FC<RevisionInstrumentCardProps> = ({
title,
subtitle,
icon,
iconColor,
iconBgColor,
onClick,
style,
}) => {
return (
<div
onClick={onClick}
style={{
background: '#ffffff',
border: 'none',
borderRadius: '14px',
padding: '14px 10px',
textAlign: 'center',
cursor: 'pointer',
boxShadow: 'none',
transition: 'transform 0.15s ease',
...style,
}}
className="ion-activatable"
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '10px',
background: iconBgColor,
color: iconColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 8px auto',
}}
>
<IonIcon icon={icon} style={{ fontSize: '18px' }} />
</div>
<span
style={{
fontSize: '0.8rem',
fontWeight: '700',
color: 'var(--ion-color-dark)',
display: 'block',
}}
>
{title}
</span>
{subtitle && (
<span
style={{
fontSize: '0.65rem',
color: 'var(--ion-color-medium)',
display: 'block',
marginTop: '2px',
}}
>
{subtitle}
</span>
)}
</div>
);
};
export default RevisionInstrumentCard;
+47
View File
@@ -0,0 +1,47 @@
import React from 'react';
import { IonProgressBar } from '@ionic/react';
interface StatsHabitCardProps {
name: string;
color: string;
completedCount: number;
totalDays: number;
streak: number;
}
const StatsHabitCard: React.FC<StatsHabitCardProps> = ({
name,
color,
completedCount,
totalDays,
streak,
}) => {
const ratio = totalDays > 0 ? completedCount / totalDays : 0;
const percentage = Math.round(ratio * 100);
return (
<div className="stats-card">
<div className="stats-card-header">
<div>
<h3>{name}</h3>
<p>
{completedCount} of {totalDays} days completed
</p>
</div>
<div className="stats-streak-badge" style={{ color }}>
{streak}🔥
</div>
</div>
<IonProgressBar
value={ratio}
style={{ '--progress-background': color } as React.CSSProperties}
/>
<div className="stats-card-footer">
<span>{percentage}% consistency</span>
<span>Current streak {streak}</span>
</div>
</div>
);
};
export default StatsHabitCard;
-97
View File
@@ -1,97 +0,0 @@
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import type { Session, User } from '@supabase/supabase-js';
import { supabase } from '../supabase';
import type { Tables } from '../database.types';
type Profile = Tables<'profiles'>;
type AuthContextValue = {
session: Session | null;
user: User | null;
profile: Profile | null;
isLoading: boolean;
refreshProfile: () => Promise<void>;
signOut: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [session, setSession] = useState<Session | null>(null);
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const loadProfile = async (userId: string | null) => {
if (!userId) {
setProfile(null);
return;
}
const { data } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.maybeSingle();
setProfile(data ?? null);
};
useEffect(() => {
let isMounted = true;
supabase.auth.getSession().then(async ({ data }) => {
if (!isMounted) return;
const nextSession = data.session ?? null;
setSession(nextSession);
setUser(nextSession?.user ?? null);
await loadProfile(nextSession?.user?.id ?? null);
if (isMounted) setIsLoading(false);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (_event, nextSession) => {
setSession(nextSession);
setUser(nextSession?.user ?? null);
await loadProfile(nextSession?.user?.id ?? null);
setIsLoading(false);
});
return () => {
isMounted = false;
subscription.unsubscribe();
};
}, []);
const value = useMemo<AuthContextValue>(
() => ({
session,
user,
profile,
isLoading,
refreshProfile: async () => loadProfile(user?.id ?? null),
signOut: async () => {
await supabase.auth.signOut();
},
}),
[session, user, profile, isLoading]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
+88 -129
View File
@@ -14,163 +14,122 @@ export type Database = {
}
public: {
Tables: {
addresses: {
habit_completions: {
Row: {
address_line: string
completed_on: string
created_at: string
habit_id: string
id: string
is_default: boolean
label: string
notes: string | null
phone: string
recipient_name: string
user_id: string
}
Insert: {
address_line: string
completed_on: string
created_at?: string
habit_id: string
id?: string
is_default?: boolean
label: string
notes?: string | null
phone: string
recipient_name: string
user_id: string
}
Update: {
address_line?: string
completed_on?: string
created_at?: string
habit_id?: string
id?: string
is_default?: boolean
label?: string
notes?: string | null
phone?: string
recipient_name?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "habit_completions_habit_id_fkey"
columns: ["habit_id"]
isOneToOne: false
referencedRelation: "habits"
referencedColumns: ["id"]
},
]
}
habits: {
Row: {
color: string
created_at: string
fcm_token: string | null
id: string
name: string
target_description: string | null
user_id: string
}
Insert: {
color?: string
created_at?: string
fcm_token?: string | null
id?: string
name: string
target_description?: string | null
user_id: string
}
Update: {
color?: string
created_at?: string
fcm_token?: string | null
id?: string
name?: string
target_description?: string | null
user_id?: string
}
Relationships: []
}
order_items: {
notes: {
Row: {
content: string
created_at: string
id: string
order_id: string
product_id: string
product_name: string
quantity: number
unit_price: number
is_favorite: boolean
title: string
user_id: string
}
Insert: {
content: string
created_at?: string
id?: string
is_favorite?: boolean
title: string
user_id?: string
}
Update: {
content?: string
created_at?: string
id?: string
is_favorite?: boolean
title?: string
user_id?: string
}
Relationships: []
}
todos: {
Row: {
created_at: string
description: string | null
due_date: string | null
id: string
is_completed: boolean
title: string
user_id: string
}
Insert: {
created_at?: string
description?: string | null
due_date?: string | null
id?: string
order_id: string
product_id: string
product_name: string
quantity: number
unit_price: number
is_completed?: boolean
title: string
user_id?: string
}
Update: {
created_at?: string
description?: string | null
due_date?: string | null
id?: string
order_id?: string
product_id?: string
product_name?: string
quantity?: number
unit_price?: number
}
Relationships: [
{
foreignKeyName: "order_items_order_id_fkey"
columns: ["order_id"]
isOneToOne: false
referencedRelation: "orders"
referencedColumns: ["id"]
},
]
}
orders: {
Row: {
address_id: string | null
created_at: string
customer_name: string | null
customer_phone: string | null
delivery_address: string | null
delivery_fee: number
delivery_notes: string | null
id: string
item_count: number
order_number: string
status: string
subtotal: number
total: number
user_id: string | null
}
Insert: {
address_id?: string | null
created_at?: string
customer_name?: string | null
customer_phone?: string | null
delivery_address?: string | null
delivery_fee?: number
delivery_notes?: string | null
id?: string
item_count?: number
order_number: string
status?: string
subtotal?: number
total?: number
user_id?: string | null
}
Update: {
address_id?: string | null
created_at?: string
customer_name?: string | null
customer_phone?: string | null
delivery_address?: string | null
delivery_fee?: number
delivery_notes?: string | null
id?: string
item_count?: number
order_number?: string
status?: string
subtotal?: number
total?: number
user_id?: string | null
}
Relationships: [
{
foreignKeyName: "orders_address_id_fkey"
columns: ["address_id"]
isOneToOne: false
referencedRelation: "addresses"
referencedColumns: ["id"]
},
]
}
profiles: {
Row: {
created_at: string
full_name: string | null
id: string
is_admin: boolean
phone: string | null
updated_at: string
}
Insert: {
created_at?: string
full_name?: string | null
id: string
is_admin?: boolean
phone?: string | null
updated_at?: string
}
Update: {
created_at?: string
full_name?: string | null
id?: string
is_admin?: boolean
phone?: string | null
updated_at?: string
is_completed?: boolean
title?: string
user_id?: string
}
Relationships: []
}
+196
View File
@@ -0,0 +1,196 @@
import React, { useEffect, useState } from 'react';
import {
IonButton,
IonContent,
IonHeader,
IonIcon,
IonItem,
IonLabel,
IonPage,
IonTitle,
IonToolbar,
IonText,
} from '@ionic/react';
import {
waterOutline,
fitnessOutline,
shieldCheckmarkOutline,
} from 'ionicons/icons';
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
import { Capacitor } from '@capacitor/core';
import { Redirect as RedirectRR, useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import {
Style,
setStatusBarBackground,
setStatusBarStyle,
} from '../utils/statusBar';
import { useIonViewWillEnter } from '@ionic/react';
import runnerDrinkingWaterImage from '../assets/runner-drinking-water-photorealistic.png';
const RedirectAny = RedirectRR as any;
const Account: React.FC = () => {
const history = useHistory();
const [sessionChecked, setSessionChecked] = useState(false);
const [email, setEmail] = useState<string | null>(null);
const [signingOut, setSigningOut] = useState(false);
const [signOutError, setSignOutError] = useState('');
useEffect(() => {
let mounted = true;
supabase.auth.getSession().then(({ data }) => {
if (!mounted) return;
setEmail(data.session?.user.email ?? null);
setSessionChecked(true);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
if (!mounted) return;
setEmail(session?.user.email ?? null);
setSessionChecked(true);
if (!session) {
history.replace('/auth');
}
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [history]);
const handleSignOut = async () => {
setSignOutError('');
setSigningOut(true);
try {
const { error } = await supabase.auth.signOut();
if (error) {
setSignOutError(error.message);
return;
}
if (Capacitor.isNativePlatform()) {
try {
await FirebaseAuthentication.signOut();
} catch {
// Ignore if no native Google session exists.
}
}
setEmail(null);
history.replace('/auth');
} catch (error) {
setSignOutError(
error instanceof Error
? error.message
: 'Unable to sign out right now.',
);
} finally {
setSigningOut(false);
}
};
if (sessionChecked && !email) {
return <RedirectAny to="/auth" />;
}
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonTitle>Account</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<div className="dashboard-shell ion-padding account-shell">
<section className="account-hero-card">
<div className="account-hero-image-wrap">
<img
src={runnerDrinkingWaterImage}
alt="Runner drinking water after a run"
className="account-hero-image"
/>
<div className="account-hero-overlay" />
</div>
<div className="account-hero-content">
<span className="eyebrow">Recovery & account</span>
<h1>{email ?? 'Loading...'}</h1>
<p>
Stay hydrated, stay consistent, and keep your habit history
safely synced across sessions.
</p>
<div className="account-highlight-row">
<div className="account-highlight-chip">
<IonIcon icon={waterOutline} />
<span>Recovery mindset</span>
</div>
<div className="account-highlight-chip subtle">
<IonIcon icon={fitnessOutline} />
<span>Built for routines</span>
</div>
</div>
</div>
</section>
<div className="stats-card account-details-card">
<IonItem lines="none" className="account-info-item">
<IonLabel>
<h2>Email</h2>
<p>{email ?? '—'}</p>
</IonLabel>
</IonItem>
<div className="account-trust-row">
<div className="account-trust-icon">
<IonIcon icon={shieldCheckmarkOutline} />
</div>
<div>
<h3>Your data is synced</h3>
<p>
Sign in to keep your streaks and progress available whenever
you come back.
</p>
</div>
</div>
<IonButton
expand="block"
className="account-primary-button"
routerLink="/home"
>
Back to Habits
</IonButton>
{signOutError ? (
<IonText color="danger">
<p className="auth-feedback">{signOutError}</p>
</IonText>
) : null}
<div></div>
<IonButton
expand="block"
fill="outline"
color="danger"
className="account-secondary-button"
onClick={handleSignOut}
disabled={signingOut}
>
{signingOut ? 'Signing Out…' : 'Sign Out'}
</IonButton>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default Account;
-254
View File
@@ -1,254 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonInput,
IonItem,
IonLabel,
IonList,
IonPage,
IonText,
IonTextarea,
IonTitle,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { trashOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../context/AuthContext';
import type { Tables } from '../database.types';
import { setStatusBarStyle, Style } from '../utils/statusBar';
type Address = Tables<'addresses'>;
const Addresses: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [addresses, setAddresses] = useState<Address[]>([]);
const [label, setLabel] = useState('Home');
const [recipientName, setRecipientName] = useState('');
const [phone, setPhone] = useState('');
const [addressLine, setAddressLine] = useState('');
const [notes, setNotes] = useState('');
const [message, setMessage] = useState('');
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const showMessage = (msg: string) => {
setMessage(msg);
window.setTimeout(() => setMessage(''), 4000);
};
const loadAddresses = async () => {
if (!user) return;
const { data } = await supabase
.from('addresses')
.select('*')
.eq('user_id', user.id)
.order('is_default', { ascending: false })
.order('created_at', { ascending: false });
setAddresses(data ?? []);
};
useEffect(() => {
loadAddresses();
}, [user?.id]);
const saveAddress = async () => {
if (
!user ||
!recipientName.trim() ||
!phone.trim() ||
!addressLine.trim()
) {
showMessage('Complete the required address details.');
return;
}
const makeDefault = addresses.length === 0;
if (makeDefault) {
await supabase
.from('addresses')
.update({ is_default: false })
.eq('user_id', user.id);
}
const { error } = await supabase.from('addresses').insert({
user_id: user.id,
label: label.trim() || 'Address',
recipient_name: recipientName.trim(),
phone: phone.trim(),
address_line: addressLine.trim(),
notes: notes.trim() || null,
is_default: makeDefault,
});
if (!error) {
setLabel('Home');
setRecipientName('');
setPhone('');
setAddressLine('');
setNotes('');
showMessage('Address saved.');
loadAddresses();
}
};
const setDefaultAddress = async (addressId: string) => {
if (!user) return;
await supabase
.from('addresses')
.update({ is_default: false })
.eq('user_id', user.id);
await supabase
.from('addresses')
.update({ is_default: true })
.eq('id', addressId);
loadAddresses();
};
const deleteAddress = async (addressId: string) => {
await supabase.from('addresses').delete().eq('id', addressId);
loadAddresses();
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonButton fill="clear" onClick={() => history.replace('/profile')}>
Back
</IonButton>
</IonButtons>
<IonTitle>Saved addresses</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
{message && (
<IonText color="medium">
<p>{message}</p>
</IonText>
)}
<div className="checkout-card">
<div className="section-heading checkout-heading">
<h2>Add address</h2>
</div>
<div className="checkout-form-grid">
<div className="form-field">
<IonLabel position="stacked">Label</IonLabel>
<IonInput
value={label}
onIonInput={(e) => setLabel(e.detail.value ?? '')}
className="checkout-input"
/>
</div>
<div className="form-field">
<IonLabel position="stacked">Recipient</IonLabel>
<IonInput
value={recipientName}
onIonInput={(e) => setRecipientName(e.detail.value ?? '')}
className="checkout-input"
/>
</div>
<div className="form-field">
<IonLabel position="stacked">Phone</IonLabel>
<IonInput
value={phone}
onIonInput={(e) => setPhone(e.detail.value ?? '')}
className="checkout-input"
/>
</div>
<div className="form-field full-width">
<IonLabel position="stacked">Address</IonLabel>
<IonTextarea
value={addressLine}
onIonInput={(e) => setAddressLine(e.detail.value ?? '')}
className="checkout-input checkout-textarea"
autoGrow
rows={3}
/>
</div>
<div className="form-field full-width">
<IonLabel position="stacked">Notes</IonLabel>
<IonTextarea
value={notes}
onIonInput={(e) => setNotes(e.detail.value ?? '')}
className="checkout-input checkout-textarea"
autoGrow
rows={3}
/>
</div>
</div>
<IonButton expand="block" onClick={saveAddress}>
Save address
</IonButton>
</div>
{addresses.length === 0 ? (
<div className="empty-state">
<h3>No saved addresses</h3>
<p className="muted-text">
Save your first address for faster checkout.
</p>
</div>
) : (
<IonList lines="none" className="orders-list">
{addresses.map((address) => (
<div key={address.id} className="order-card">
<div className="order-card-top">
<div>
<span className="order-label">{address.label}</span>
<h3>{address.recipient_name}</h3>
</div>
{address.is_default ? (
<span className="default-pill">Default</span>
) : (
<IonButton
size="small"
fill="outline"
onClick={() => setDefaultAddress(address.id)}
>
Make default
</IonButton>
)}
</div>
<div className="delivery-detail-row delivery-detail-block">
<span>Phone</span>
<strong>{address.phone}</strong>
</div>
<div className="delivery-detail-row delivery-detail-block">
<span>Address</span>
<strong>{address.address_line}</strong>
</div>
{address.notes && (
<div className="delivery-detail-row delivery-detail-block">
<span>Notes</span>
<strong>{address.notes}</strong>
</div>
)}
<IonButton
fill="clear"
color="danger"
onClick={() => deleteAddress(address.id)}
>
<IonIcon slot="start" icon={trashOutline} />
Delete
</IonButton>
</div>
))}
</IonList>
)}
</IonContent>
</IonPage>
);
};
export default Addresses;
-117
View File
@@ -1,117 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonItem,
IonLabel,
IonList,
IonPage,
IonSelect,
IonSelectOption,
IonTitle,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { RouteComponentProps, useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import type { Tables } from '../database.types';
import { setStatusBarStyle, Style } from '../utils/statusBar';
type Props = RouteComponentProps<{ id: string }>;
type Order = Tables<'orders'>;
type OrderItem = Tables<'order_items'>;
const statuses = ['confirmed', 'packed', 'out_for_delivery', 'delivered'];
const AdminOrderDetail: React.FC<Props> = ({ match }) => {
const history = useHistory();
const [order, setOrder] = useState<Order | null>(null);
const [items, setItems] = useState<OrderItem[]>([]);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const loadOrder = async () => {
const [{ data: orderData }, { data: itemData }] = await Promise.all([
supabase.from('orders').select('*').eq('id', match.params.id).single(),
supabase.from('order_items').select('*').eq('order_id', match.params.id),
]);
setOrder(orderData ?? null);
setItems(itemData ?? []);
};
useEffect(() => {
loadOrder();
}, [match.params.id]);
const updateStatus = async (status: string) => {
await supabase.from('orders').update({ status }).eq('id', match.params.id);
loadOrder();
};
if (!order)
return (
<IonPage>
<IonContent />
</IonPage>
);
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonButton
fill="clear"
onClick={() => history.replace('/admin/orders')}
>
Back
</IonButton>
</IonButtons>
<IonTitle>{order.order_number}</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div className="tracking-summary-card">
<div className="delivery-detail-row">
<span>Customer</span>
<strong>{order.customer_name || '—'}</strong>
</div>
<div className="delivery-detail-row">
<span>Phone</span>
<strong>{order.customer_phone || '—'}</strong>
</div>
<div className="delivery-detail-row delivery-detail-block">
<span>Address</span>
<strong>{order.delivery_address || '—'}</strong>
</div>
<IonSelect
value={order.status}
interface="action-sheet"
onIonChange={(e) => updateStatus(e.detail.value)}
>
{statuses.map((status) => (
<IonSelectOption key={status} value={status}>
{status.replace(/_/g, ' ')}
</IonSelectOption>
))}
</IonSelect>
</div>
<IonList lines="none" className="cart-list">
{items.map((item) => (
<IonItem key={item.id} className="cart-item">
<IonLabel>
<h2>{item.product_name}</h2>
<p>Qty {item.quantity}</p>
</IonLabel>
</IonItem>
))}
</IonList>
</IonContent>
</IonPage>
);
};
export default AdminOrderDetail;
-100
View File
@@ -1,100 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonItem,
IonLabel,
IonList,
IonPage,
IonSelect,
IonSelectOption,
IonTitle,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import type { Tables } from '../database.types';
import { setStatusBarStyle, Style } from '../utils/statusBar';
type Order = Tables<'orders'>;
const statuses = ['confirmed', 'packed', 'out_for_delivery', 'delivered'];
const AdminOrders: React.FC = () => {
const history = useHistory();
const [orders, setOrders] = useState<Order[]>([]);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const loadOrders = async () => {
const { data } = await supabase
.from('orders')
.select('*')
.order('created_at', { ascending: false });
setOrders(data ?? []);
};
useEffect(() => {
loadOrders();
}, []);
const updateStatus = async (orderId: string, status: string) => {
await supabase.from('orders').update({ status }).eq('id', orderId);
loadOrders();
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonButton fill="clear" onClick={() => history.replace('/profile')}>
Back
</IonButton>
</IonButtons>
<IonTitle>Admin orders</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<IonList lines="none" className="orders-list">
{orders.map((order) => (
<IonItem
key={order.id}
className="cart-item admin-order-item"
button
onClick={() => history.push(`/admin/orders/${order.id}`)}
>
<IonLabel>
<h2>{order.order_number}</h2>
<p>
{order.customer_name || 'Customer'} · $
{Number(order.total).toFixed(2)}
</p>
</IonLabel>
<IonSelect
value={order.status}
interface="popover"
onIonChange={(e) => {
e.stopPropagation();
updateStatus(order.id, e.detail.value);
}}
>
{statuses.map((status) => (
<IonSelectOption key={status} value={status}>
{status.replace(/_/g, ' ')}
</IonSelectOption>
))}
</IonSelect>
</IonItem>
))}
</IonList>
</IonContent>
</IonPage>
);
};
export default AdminOrders;
+316 -121
View File
@@ -1,113 +1,111 @@
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import {
IonButton,
IonCard,
IonCardContent,
IonContent,
IonHeader,
IonIcon,
IonInput,
IonItem,
IonLabel,
IonPage,
IonSegment,
IonSegmentButton,
IonText,
IonTitle,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
import { Capacitor } from '@capacitor/core';
import { Redirect, useHistory } from 'react-router-dom';
import {
Style,
setStatusBarBackground,
setStatusBarStyle,
} from '../utils/statusBar';
import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const Auth: React.FC = () => {
const history = useHistory();
const [mode, setMode] = useState<'login' | 'signup'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Light);
});
const [loading, setLoading] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
const [appleLoading, setAppleLoading] = useState(false);
const [error, setError] = useState('');
const [info, setInfo] = useState('');
const [verificationMessage, setVerificationMessage] = useState('');
const [sessionChecked, setSessionChecked] = useState(false);
const [hasSession, setHasSession] = useState(false);
const showError = (msg: string) => {
setErrorMessage(msg);
window.setTimeout(() => setErrorMessage(''), 4000);
setError(msg);
setTimeout(() => {
setError((current) => (current === msg ? '' : current));
}, 4000);
};
const showMessage = (msg: string) => {
setMessage(msg);
window.setTimeout(() => setMessage(''), 4000);
};
const normaliseAuthError = (msg: string) => {
const lowered = msg.toLowerCase();
if (lowered.includes('email not confirmed')) {
return 'Please confirm your email before signing in. Check your inbox for the verification email.';
}
if (lowered.includes('invalid login credentials')) {
return 'Incorrect email or password. Please try again.';
}
if (lowered.includes('user already registered')) {
return 'An account with this email already exists. Please sign in instead.';
}
return msg;
};
const handleResendConfirmation = async () => {
if (!email.trim()) {
showError(
'Enter your email address first so we can resend the confirmation email.'
);
return;
}
setIsSubmitting(true);
const { error } = await supabase.auth.resend({
type: 'signup',
email: email.trim(),
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
setStatusBarBackground('#f6f7fb');
});
setIsSubmitting(false);
if (error) {
showError(normaliseAuthError(error.message));
return;
}
React.useEffect(() => {
let mounted = true;
showMessage('Confirmation email sent. Please check your inbox.');
supabase.auth.getSession().then(({ data }) => {
if (!mounted) return;
setHasSession(Boolean(data.session));
setSessionChecked(true);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
if (!mounted) return;
setHasSession(Boolean(session));
setSessionChecked(true);
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, []);
const formValid = useMemo(() => {
return email.trim().length > 3 && password.length >= 6;
}, [email, password]);
const handleSubmit = async () => {
if (!email.trim() || password.trim().length < 6) {
showError('Enter a valid email and password with at least 6 characters.');
return;
}
setError('');
setInfo('');
setVerificationMessage('');
setLoading(true);
setIsSubmitting(true);
try {
if (mode === 'login') {
const { error } = await supabase.auth.signInWithPassword({
const { error: signInError } = await supabase.auth.signInWithPassword({
email: email.trim(),
password,
});
setIsSubmitting(false);
if (error) {
showError(normaliseAuthError(error.message));
if (signInError) {
showError(signInError.message);
return;
}
history.replace('/home');
return;
}
const { data, error } = await supabase.auth.signUp({
const { data, error: signUpError } = await supabase.auth.signUp({
email: email.trim(),
password,
});
setIsSubmitting(false);
if (error) {
showError(normaliseAuthError(error.message));
if (signUpError) {
showError(signUpError.message);
return;
}
@@ -116,84 +114,281 @@ const Auth: React.FC = () => {
return;
}
showMessage('Account created! Check your email for a verification link.');
setVerificationMessage(
'Account created! Check your email for a verification link.'
);
setInfo('Once verified, come back and sign in.');
} finally {
setLoading(false);
}
};
const handleGoogleLogin = async () => {
setError('');
setInfo('');
setVerificationMessage('');
setGoogleLoading(true);
if (!Capacitor.isNativePlatform()) {
showError(
'Google Sign-In is only available in the native app. Use email to sign in here.'
);
setGoogleLoading(false);
return;
}
try {
const result = await FirebaseAuthentication.signInWithGoogle();
const idToken = result.credential?.idToken;
if (!idToken) {
showError('Google sign-in did not return an ID token.');
return;
}
const { error: googleAuthError } = await supabase.auth.signInWithIdToken({
provider: 'google',
token: idToken,
});
if (googleAuthError) {
showError(googleAuthError.message);
return;
}
history.replace('/home');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Google sign-in failed.';
showError(message);
} finally {
setGoogleLoading(false);
}
};
const handleAppleLogin = async () => {
setError('');
setInfo('');
setVerificationMessage('');
setAppleLoading(true);
if (!Capacitor.isNativePlatform()) {
showError(
'Apple Sign-In is only available in the native app. Use email to sign in here.'
);
setAppleLoading(false);
return;
}
try {
const result = await FirebaseAuthentication.signInWithApple({
skipNativeAuth: true,
});
const idToken = result.credential?.idToken;
const rawNonce = result.credential?.nonce;
if (!idToken) {
showError('Apple sign-in did not return an ID token.');
return;
}
const { error: appleAuthError } = await supabase.auth.signInWithIdToken({
provider: 'apple',
token: idToken,
nonce: rawNonce || undefined,
});
if (appleAuthError) {
showError(appleAuthError.message);
return;
}
history.replace('/home');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Apple sign-in failed.';
showError(message);
} finally {
setAppleLoading(false);
}
};
if (sessionChecked && hasSession) {
return <Redirect to="/home" />;
}
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonTitle>
{mode === 'login' ? 'Welcome back' : 'Create account'}
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding auth-page">
<div className="auth-card">
<h1>
{mode === 'login'
? 'Sign in to continue'
: 'Create your shopping account'}
</h1>
<p className="muted-text">
Save your orders, addresses, and track deliveries securely.
<IonContent fullscreen style={{ '--background': '#f6f7fb' }}>
<div className="auth-shell">
<div className="auth-hero">
<span className="eyebrow">Protect your streaks</span>
<h1>Build habits that actually stick.</h1>
<p>
Track daily wins, recover faster from misses, and see which habits
are getting real momentum.
</p>
</div>
{errorMessage && (
<IonText color="danger">
<p>{errorMessage}</p>
</IonText>
)}
{message && (
<IonText color="success">
<p>{message}</p>
</IonText>
)}
<IonCard className="auth-card">
<IonCardContent>
<IonSegment
value={mode}
onIonChange={(e) =>
setMode(e.detail.value as 'login' | 'signup')
}
>
<IonSegmentButton value="login">
<IonLabel>Login</IonLabel>
</IonSegmentButton>
<IonSegmentButton value="signup">
<IonLabel>Sign Up</IonLabel>
</IonSegmentButton>
</IonSegment>
{verificationMessage ? (
<div className="auth-message-block">
<h2>Check your inbox</h2>
<p>{verificationMessage}</p>
<IonText color="medium">
<p>{info}</p>
</IonText>
<IonButton fill="clear" onClick={() => setMode('login')}>
Back to Login
</IonButton>
</div>
) : (
<div className="auth-form">
<IonItem lines="none" className="auth-input-item">
<IonLabel position="stacked">Email</IonLabel>
<IonInput
value={email}
type="email"
label="Email"
labelPlacement="stacked"
value={email}
placeholder="you@example.com"
onIonInput={(e) => setEmail(e.detail.value ?? '')}
/>
</IonItem>
<IonItem lines="none" className="auth-input-item">
<IonLabel position="stacked">Password</IonLabel>
<IonInput
value={password}
type="password"
label="Password"
labelPlacement="stacked"
value={password}
placeholder="At least 6 characters"
onIonInput={(e) => setPassword(e.detail.value ?? '')}
/>
</IonItem>
{error ? (
<IonText color="danger">
<p className="auth-feedback">{error}</p>
</IonText>
) : null}
{info ? (
<IonText color="medium">
<p className="auth-feedback">{info}</p>
</IonText>
) : null}
<IonButton
expand="block"
disabled={
!formValid || loading || googleLoading || appleLoading
}
onClick={handleSubmit}
disabled={isSubmitting}
>
{mode === 'login' ? 'Sign in' : 'Create account'}
</IonButton>
{mode === 'login' && (
<IonButton
fill="clear"
expand="block"
onClick={handleResendConfirmation}
disabled={isSubmitting}
>
Resend confirmation email
</IonButton>
)}
<IonButton
fill="clear"
expand="block"
onClick={() => setMode(mode === 'login' ? 'signup' : 'login')}
>
{mode === 'login'
? 'Need an account? Sign up'
: 'Already have an account? Sign in'}
{loading
? 'Please wait…'
: mode === 'login'
? 'Log In'
: 'Create Account'}
</IonButton>
{mode === 'login' ? (
<>
<div className="auth-divider">
<span>or</span>
</div>
<IonButton
expand="block"
fill="outline"
className="google-auth-button"
disabled={loading || googleLoading || appleLoading}
onClick={handleGoogleLogin}
>
<span
slot="start"
className="google-svg-wrap"
style={{
display: 'inline-flex',
alignItems: 'center',
marginRight: '8px',
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
style={{ display: 'block' }}
>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
</span>
{googleLoading ? 'Connecting…' : 'Continue with Google'}
</IonButton>
<IonButton
expand="block"
fill="outline"
className="apple-auth-button"
disabled={loading || googleLoading || appleLoading}
onClick={handleAppleLogin}
>
<span
slot="start"
style={{
display: 'inline-flex',
alignItems: 'center',
marginRight: '8px',
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
style={{ display: 'block' }}
>
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
</span>
{appleLoading ? 'Connecting…' : 'Continue with Apple'}
</IonButton>
</>
) : null}
</div>
)}
</IonCardContent>
</IonCard>
</div>
</IonContent>
</IonPage>
-485
View File
@@ -1,485 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonInput,
IonItem,
IonLabel,
IonList,
IonLoading,
IonPage,
IonSelect,
IonSelectOption,
IonTextarea,
IonText,
IonTitle,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import {
addOutline,
bagHandleOutline,
removeOutline,
trashOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import type { Tables, TablesInsert } from '../database.types';
import { useAuth } from '../context/AuthContext';
import { CartItem, LAST_ORDER_STORAGE_KEY, readCart, writeCart } from './Home';
import { setStatusBarStyle, Style } from '../utils/statusBar';
type Address = Tables<'addresses'>;
type CheckoutForm = {
customerName: string;
customerPhone: string;
deliveryAddress: string;
deliveryNotes: string;
addressId: string;
};
type FormErrors = Partial<Record<keyof CheckoutForm, string>>;
type SavedOrderInfo = {
id: string;
orderNumber: string;
total: number;
itemCount: number;
status: string;
customerName: string;
customerPhone: string;
deliveryAddress: string;
deliveryNotes: string;
};
const initialForm: CheckoutForm = {
customerName: '',
customerPhone: '',
deliveryAddress: '',
deliveryNotes: '',
addressId: '',
};
const Cart: React.FC = () => {
const history = useHistory();
const { user, profile } = useAuth();
const guestCheckoutEnabled = true;
const [cart, setCart] = useState<CartItem[]>([]);
const [addresses, setAddresses] = useState<Address[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [form, setForm] = useState<CheckoutForm>(initialForm);
const [formErrors, setFormErrors] = useState<FormErrors>({});
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
useEffect(() => {
setCart(readCart());
}, []);
useEffect(() => {
const syncCartFromStorage = () => setCart(readCart());
window.addEventListener('storage', syncCartFromStorage);
window.addEventListener(
'cart:updated',
syncCartFromStorage as EventListener
);
return () => {
window.removeEventListener('storage', syncCartFromStorage);
window.removeEventListener(
'cart:updated',
syncCartFromStorage as EventListener
);
};
}, []);
useEffect(() => {
const loadAddresses = async () => {
if (!user) {
setAddresses([]);
setForm((current) => ({
...current,
customerName: current.customerName || profile?.full_name || '',
customerPhone: current.customerPhone || profile?.phone || '',
addressId: '',
}));
return;
}
const { data } = await supabase
.from('addresses')
.select('*')
.eq('user_id', user.id)
.order('is_default', { ascending: false });
const nextAddresses = data ?? [];
setAddresses(nextAddresses);
const defaultAddress =
nextAddresses.find((address) => address.is_default) ?? nextAddresses[0];
setForm((current) => ({
...current,
customerName:
current.customerName ||
profile?.full_name ||
defaultAddress?.recipient_name ||
'',
customerPhone:
current.customerPhone ||
profile?.phone ||
defaultAddress?.phone ||
'',
deliveryAddress:
current.deliveryAddress || defaultAddress?.address_line || '',
deliveryNotes: current.deliveryNotes || defaultAddress?.notes || '',
addressId: defaultAddress?.id || '',
}));
};
void loadAddresses();
}, [user?.id, profile?.full_name, profile?.phone]);
const syncCart = (nextCart: CartItem[]) => {
setCart(nextCart);
writeCart(nextCart);
};
const showError = (message: string) => {
setErrorMessage(message);
window.setTimeout(() => setErrorMessage(''), 4000);
};
const handleFieldChange = (field: keyof CheckoutForm, value: string) => {
setForm((current) => ({ ...current, [field]: value }));
if (formErrors[field])
setFormErrors((current) => ({ ...current, [field]: undefined }));
};
const handleAddressSelect = (addressId: string) => {
const selected = addresses.find((address) => address.id === addressId);
if (!selected) return;
setForm((current) => ({
...current,
addressId,
customerName: selected.recipient_name,
customerPhone: selected.phone,
deliveryAddress: selected.address_line,
deliveryNotes: selected.notes || '',
}));
};
const validateForm = () => {
const nextErrors: FormErrors = {};
if (!form.customerName.trim())
nextErrors.customerName = 'Please enter the customer name.';
if (form.customerPhone.trim().replace(/\D/g, '').length < 7)
nextErrors.customerPhone = 'Please enter a valid phone number.';
if (form.deliveryAddress.trim().length < 10)
nextErrors.deliveryAddress =
'Please add a more complete delivery address.';
setFormErrors(nextErrors);
return Object.keys(nextErrors).length === 0;
};
const itemCount = useMemo(
() => cart.reduce((sum, item) => sum + item.quantity, 0),
[cart]
);
const subtotal = useMemo(
() => cart.reduce((sum, item) => sum + item.price * item.quantity, 0),
[cart]
);
const deliveryFee = cart.length > 0 ? 2.99 : 0;
const total = subtotal + deliveryFee;
const updateQuantity = (id: string, delta: number) =>
syncCart(
cart
.map((item) =>
item.id === id ? { ...item, quantity: item.quantity + delta } : item
)
.filter((item) => item.quantity > 0)
);
const removeItem = (id: string) =>
syncCart(cart.filter((item) => item.id !== id));
const placeOrder = async () => {
if (cart.length === 0 || isSubmitting) return;
if (!validateForm()) {
showError('Please complete the checkout form before placing the order.');
return;
}
setIsSubmitting(true);
const orderNumber = `QC-${Date.now().toString().slice(-6)}`;
const orderPayload: TablesInsert<'orders'> = {
order_number: orderNumber,
subtotal,
delivery_fee: deliveryFee,
total,
item_count: itemCount,
status: 'confirmed',
customer_name: form.customerName.trim(),
customer_phone: form.customerPhone.trim(),
delivery_address: form.deliveryAddress.trim(),
delivery_notes: form.deliveryNotes.trim() || null,
user_id: guestCheckoutEnabled ? (user?.id ?? null) : user?.id,
address_id: user ? form.addressId || null : null,
};
const { data: order, error: orderError } = await supabase
.from('orders')
.insert(orderPayload)
.select()
.single();
if (orderError || !order) {
setIsSubmitting(false);
showError('We could not place your order. Please try again.');
return;
}
const itemsPayload: TablesInsert<'order_items'>[] = cart.map((item) => ({
order_id: order.id,
product_id: item.id,
product_name: item.name,
unit_price: item.price,
quantity: item.quantity,
}));
const { error: itemsError } = await supabase
.from('order_items')
.insert(itemsPayload);
if (itemsError) {
await supabase.from('orders').delete().eq('id', order.id);
setIsSubmitting(false);
showError('We could not save your order items. Please try again.');
return;
}
const savedOrder: SavedOrderInfo = {
id: order.id,
orderNumber: order.order_number,
total: Number(order.total),
itemCount: order.item_count,
status: order.status,
customerName: order.customer_name ?? '',
customerPhone: order.customer_phone ?? '',
deliveryAddress: order.delivery_address ?? '',
deliveryNotes: order.delivery_notes ?? '',
};
localStorage.setItem(LAST_ORDER_STORAGE_KEY, JSON.stringify(savedOrder));
writeCart([]);
setCart([]);
setIsSubmitting(false);
history.replace('/order-confirmation');
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonButton fill="clear" onClick={() => history.replace('/home')}>
Back
</IonButton>
</IonButtons>
<IonTitle>Your Cart</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<IonLoading isOpen={isSubmitting} message="Placing your order..." />
{errorMessage && (
<IonText color="danger">
<p>{errorMessage}</p>
</IonText>
)}
{cart.length === 0 ? (
<div className="empty-state cart-empty">
<IonIcon icon={bagHandleOutline} />
<h3>Your cart is empty</h3>
<p className="muted-text">
Add your first product to place an order.
</p>
<IonButton onClick={() => history.push('/home')}>
Browse products
</IonButton>
</div>
) : (
<>
<div className="cart-overview-card">
<div>
<p className="overview-label">Items in cart</p>
<h2>
{itemCount} item{itemCount === 1 ? '' : 's'}
</h2>
</div>
<IonText color="medium">
<p>Review your selections before placing the order.</p>
</IonText>
</div>
<IonList lines="none" className="cart-list">
{cart.map((item) => (
<IonItem key={item.id} className="cart-item">
<IonLabel>
<h2>{item.name}</h2>
<p>${item.price.toFixed(2)} each</p>
<strong>${(item.price * item.quantity).toFixed(2)}</strong>
</IonLabel>
<div className="cart-actions">
<IonButton
fill="clear"
onClick={() => updateQuantity(item.id, -1)}
>
<IonIcon icon={removeOutline} />
</IonButton>
<span>{item.quantity}</span>
<IonButton
fill="clear"
onClick={() => updateQuantity(item.id, 1)}
>
<IonIcon icon={addOutline} />
</IonButton>
<IonButton
fill="clear"
color="danger"
onClick={() => removeItem(item.id)}
>
<IonIcon icon={trashOutline} />
</IonButton>
</div>
</IonItem>
))}
</IonList>
<div className="checkout-card">
<div className="section-heading checkout-heading">
<h2>Delivery details</h2>
<IonText color="medium">
{user ? 'Required for checkout' : 'Guest checkout is enabled'}
</IonText>
</div>
{user && addresses.length > 0 && (
<div className="form-field full-width">
<IonLabel position="stacked">Saved address</IonLabel>
<IonSelect
value={form.addressId}
placeholder="Choose a saved address"
className="checkout-input"
onIonChange={(e) => handleAddressSelect(e.detail.value)}
>
{addresses.map((address) => (
<IonSelectOption key={address.id} value={address.id}>
{address.label} · {address.address_line}
</IonSelectOption>
))}
</IonSelect>
</div>
)}
<div className="checkout-form-grid">
<div className="form-field">
<IonLabel position="stacked">Customer name</IonLabel>
<IonInput
value={form.customerName}
placeholder="Enter full name"
className="checkout-input"
onIonInput={(e) =>
handleFieldChange('customerName', e.detail.value ?? '')
}
/>
{formErrors.customerName && (
<IonText color="danger" className="field-error">
<p>{formErrors.customerName}</p>
</IonText>
)}
</div>
<div className="form-field">
<IonLabel position="stacked">Phone number</IonLabel>
<IonInput
value={form.customerPhone}
type="tel"
inputmode="tel"
placeholder="e.g. +263 77 123 4567"
className="checkout-input"
onIonInput={(e) =>
handleFieldChange('customerPhone', e.detail.value ?? '')
}
/>
{formErrors.customerPhone && (
<IonText color="danger" className="field-error">
<p>{formErrors.customerPhone}</p>
</IonText>
)}
</div>
<div className="form-field full-width">
<IonLabel position="stacked">Delivery address</IonLabel>
<IonTextarea
value={form.deliveryAddress}
autoGrow
rows={3}
placeholder="House number, street, suburb, city"
className="checkout-input checkout-textarea"
onIonInput={(e) =>
handleFieldChange('deliveryAddress', e.detail.value ?? '')
}
/>
{formErrors.deliveryAddress && (
<IonText color="danger" className="field-error">
<p>{formErrors.deliveryAddress}</p>
</IonText>
)}
</div>
<div className="form-field full-width">
<IonLabel position="stacked">Delivery notes</IonLabel>
<IonTextarea
value={form.deliveryNotes}
autoGrow
rows={3}
placeholder="Gate code, landmark, or helpful instructions"
className="checkout-input checkout-textarea"
onIonInput={(e) =>
handleFieldChange('deliveryNotes', e.detail.value ?? '')
}
/>
</div>
</div>
{user && (
<IonButton
fill="outline"
expand="block"
onClick={() => history.push('/addresses')}
>
Manage saved addresses
</IonButton>
)}
</div>
<div className="summary-card">
<div className="summary-row">
<IonText color="medium">Items</IonText>
<strong>{itemCount}</strong>
</div>
<div className="summary-row">
<IonText color="medium">Subtotal</IonText>
<strong>${subtotal.toFixed(2)}</strong>
</div>
<div className="summary-row">
<IonText color="medium">Delivery</IonText>
<strong>${deliveryFee.toFixed(2)}</strong>
</div>
<div className="summary-row total-row">
<span>Total</span>
<strong>${total.toFixed(2)}</strong>
</div>
<IonButton
expand="block"
onClick={placeOrder}
disabled={isSubmitting}
>
Place order
</IonButton>
</div>
</>
)}
</IonContent>
</IonPage>
);
};
export default Cart;
+623
View File
@@ -0,0 +1,623 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
IonAlert,
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonPage,
IonSkeletonText,
IonTitle,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { IonBackButton } from '@ionic/react';
import {
checkmarkOutline,
createOutline,
ellipseOutline,
flameOutline,
sparklesOutline,
trashOutline,
trendingUpOutline,
} from 'ionicons/icons';
import { Redirect, useHistory, useParams } from 'react-router-dom';
import type { Tables } from '../database.types';
import { supabase } from '../supabase';
import HabitFormModal from '../components/HabitFormModal';
import {
Style,
setStatusBarBackground,
setStatusBarStyle,
} from '../utils/statusBar';
interface HabitWithCompletions extends Tables<'habits'> {
completions: Tables<'habit_completions'>[];
}
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
const calculateStreak = (dates: string[]) => {
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
let streak = 0;
let cursor = new Date();
if (!unique.includes(formatDate(cursor))) {
cursor.setDate(cursor.getDate() - 1);
}
while (unique.includes(formatDate(cursor))) {
streak += 1;
cursor.setDate(cursor.getDate() - 1);
}
return streak;
};
const HabitDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const history = useHistory();
const [sessionChecked, setSessionChecked] = useState(false);
const [userId, setUserId] = useState<string | null>(null);
const [habit, setHabit] = useState<HabitWithCompletions | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const loadHabit = useCallback(async () => {
setError('');
const {
data: { session },
error: sessionError,
} = await supabase.auth.getSession();
if (sessionError) {
setError(sessionError.message);
setLoading(false);
return;
}
if (!session?.user) {
setUserId(null);
setSessionChecked(true);
setLoading(false);
return;
}
setUserId(session.user.id);
setSessionChecked(true);
const { data, error: habitError } = await supabase
.from('habits')
.select('*, completions:habit_completions(*)')
.eq('id', id)
.single();
if (habitError) {
if (habitError.code === 'PGRST116') {
setHabit(null);
} else {
setError(habitError.message);
}
setLoading(false);
return;
}
setHabit(data as HabitWithCompletions);
setLoading(false);
}, [id]);
useEffect(() => {
loadHabit();
}, [loadHabit]);
const completionDates = useMemo(
() => habit?.completions.map((c) => c.completed_on) ?? [],
[habit],
);
const today = formatDate(new Date());
const doneToday = completionDates.includes(today);
const currentStreak = useMemo(
() => calculateStreak(completionDates),
[completionDates],
);
const totalCompletions = completionDates.length;
// Calculate longest streak
const longestStreak = useMemo(() => {
const unique = Array.from(new Set(completionDates)).sort((a, b) =>
a.localeCompare(b),
);
if (unique.length === 0) return 0;
let maxStreak = 1;
let current = 1;
for (let i = 1; i < unique.length; i++) {
const prev = new Date(unique[i - 1]);
const curr = new Date(unique[i]);
const diffDays =
(curr.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24);
if (diffDays === 1) {
current += 1;
maxStreak = Math.max(maxStreak, current);
} else {
current = 1;
}
}
return maxStreak;
}, [completionDates]);
// Days since habit creation (or first completion, whichever is earlier)
const daysSinceStart = useMemo(() => {
const startDate = habit
? new Date(
Math.min(
new Date(habit.created_at).getTime(),
...completionDates.map((d) => new Date(d).getTime()),
Date.now(),
),
)
: new Date();
const diff = Math.ceil(
(Date.now() - startDate.getTime()) / (1000 * 60 * 60 * 24),
);
return Math.max(diff, 1);
}, [habit, completionDates]);
const consistencyRate = Math.round((totalCompletions / daysSinceStart) * 100);
// 30-day heatmap data
const heatmapDays = useMemo(() => {
return Array.from({ length: 30 }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - (29 - i));
const key = formatDate(date);
return {
key,
completed: completionDates.includes(key),
isToday: key === today,
};
});
}, [completionDates, today]);
const toggleToday = async () => {
if (!userId || !habit) return;
setBusy(true);
if (doneToday) {
const existing = habit.completions.find((c) => c.completed_on === today);
if (existing) {
const { error: deleteError } = await supabase
.from('habit_completions')
.delete()
.eq('id', existing.id);
if (deleteError) setError(deleteError.message);
}
} else {
const { error: insertError } = await supabase
.from('habit_completions')
.insert({
habit_id: habit.id,
user_id: userId,
completed_on: today,
});
if (insertError) setError(insertError.message);
}
await loadHabit();
setBusy(false);
};
const updateHabit = async (values: {
name: string;
targetDescription: string;
color: string;
}) => {
if (!userId || !habit) throw new Error('Habit not found.');
const { error: updateError } = await supabase
.from('habits')
.update({
name: values.name,
target_description: values.targetDescription || null,
color: values.color,
})
.eq('id', habit.id);
if (updateError) throw updateError;
await loadHabit();
};
const deleteHabit = async () => {
if (!habit) return;
setBusy(true);
const { error: deleteError } = await supabase
.from('habits')
.delete()
.eq('id', habit.id);
if (deleteError) {
setError(deleteError.message);
setBusy(false);
return;
}
history.push('/home');
};
if (sessionChecked && !userId) {
return <Redirect to="/auth" />;
}
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/home" />
</IonButtons>
<IonTitle>{habit?.name ?? 'Habit'}</IonTitle>
<IonButtons slot="end">
{habit && (
<>
<IonButton fill="clear" onClick={() => setShowEditModal(true)}>
<IonIcon icon={createOutline} />
</IonButton>
<IonButton
fill="clear"
color="danger"
onClick={() => setShowDeleteAlert(true)}
>
<IonIcon icon={trashOutline} />
</IonButton>
</>
)}
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<div className="dashboard-shell ion-padding">
{/* Loading state */}
{loading ? (
<>
<IonSkeletonText
animated
style={{ width: '60%', height: '24px', borderRadius: '8px' }}
/>
<IonSkeletonText
animated
style={{
width: '100%',
height: '120px',
borderRadius: '24px',
}}
/>
<IonSkeletonText
animated
style={{
width: '100%',
height: '160px',
borderRadius: '24px',
}}
/>
<IonSkeletonText
animated
style={{
width: '100%',
height: '100px',
borderRadius: '24px',
}}
/>
</>
) : error ? (
/* Error state */
<div className="inline-message error-card">
<h3>Something went wrong</h3>
<p>{error}</p>
<IonButton onClick={loadHabit}>Try Again</IonButton>
</div>
) : !habit ? (
/* Not found state */
<div className="inline-message">
<h3>Habit not found</h3>
<p>
This habit may have been deleted or you don&apos;t have access
to it.
</p>
<IonButton routerLink="/home">Back to habits</IonButton>
</div>
) : (
<>
{/* Habit info card */}
<section className="hero-card compact">
<div className="habit-copy-topline">
<span
className="habit-color-pill"
style={{ background: habit.color }}
/>
<span className="eyebrow">Your routine</span>
</div>
<h1>{habit.name}</h1>
<p>
{habit.target_description ||
'Show up today and keep the chain alive.'}
</p>
{/* Today toggle */}
<div style={{ marginTop: '18px' }}>
<IonButton
expand="block"
fill={doneToday ? 'solid' : 'outline'}
onClick={toggleToday}
disabled={busy}
style={
doneToday
? ({
'--background': habit.color,
'--background-hover': habit.color,
'--background-activated': habit.color,
'--border-radius': '18px',
minHeight: '50px',
} as any)
: ({
'--border-color': habit.color,
'--color': habit.color,
'--border-radius': '18px',
minHeight: '50px',
} as any)
}
>
<IonIcon
icon={doneToday ? checkmarkOutline : ellipseOutline}
slot="start"
/>
{doneToday ? 'Completed today' : "Mark today's habit done"}
</IonButton>
</div>
</section>
{/* All-time stats */}
<section>
<p className="home-section-label">All-time stats</p>
<div className="stack-list">
{/* Stats row */}
<div
className="hero-card compact"
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gap: '12px',
textAlign: 'center',
}}
>
<div>
<div
style={{
fontSize: '1.8rem',
fontWeight: 800,
color: habit.color,
lineHeight: 1,
}}
>
{totalCompletions}
</div>
<div
style={{
fontSize: '0.78rem',
color: 'var(--ion-color-medium)',
marginTop: '4px',
}}
>
total done
</div>
</div>
<div>
<div
style={{
fontSize: '1.8rem',
fontWeight: 800,
color: habit.color,
lineHeight: 1,
}}
>
{longestStreak}
</div>
<div
style={{
fontSize: '0.78rem',
color: 'var(--ion-color-medium)',
marginTop: '4px',
}}
>
best streak
</div>
</div>
<div>
<div
style={{
fontSize: '1.8rem',
fontWeight: 800,
color: habit.color,
lineHeight: 1,
}}
>
{consistencyRate}%
</div>
<div
style={{
fontSize: '0.78rem',
color: 'var(--ion-color-medium)',
marginTop: '4px',
}}
>
consistency
</div>
</div>
</div>
{/* Current streak highlight */}
<div
className="hero-card compact"
style={{
display: 'flex',
alignItems: 'center',
gap: '14px',
}}
>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '16px',
background: `${habit.color}18`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={flameOutline}
style={{ color: habit.color, fontSize: '1.3rem' }}
/>
</div>
<div>
<h3
style={{
margin: 0,
fontSize: '1rem',
fontWeight: 700,
color: 'var(--ion-color-dark)',
}}
>
{currentStreak} day{currentStreak === 1 ? '' : 's'} and
counting
</h3>
<p
style={{
margin: '4px 0 0',
color: 'var(--ion-color-medium)',
fontSize: '0.84rem',
}}
>
{currentStreak > 0
? `You've shown up every day since ${(() => {
const d = new Date();
d.setDate(d.getDate() - currentStreak + 1);
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
})()}.`
: "Start today and build a streak you're proud of."}
</p>
</div>
</div>
</div>
</section>
{/* 30-day heatmap */}
<section>
<p className="home-section-label">Last 30 days</p>
<div className="hero-card compact">
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: '6px',
}}
>
{heatmapDays.map((day) => (
<div
key={day.key}
title={
day.isToday
? 'Today'
: new Date(day.key).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
}
style={{
aspectRatio: '1',
borderRadius: '6px',
background: day.completed
? habit.color
: `${habit.color}14`,
border: day.isToday
? `2px solid ${habit.color}`
: '2px solid transparent',
transition: 'background 0.2s',
}}
/>
))}
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: '12px',
fontSize: '0.74rem',
color: 'var(--ion-color-medium)',
}}
>
<span>30 days ago</span>
<span>
<IonIcon
icon={trendingUpOutline}
style={{ verticalAlign: 'middle', marginRight: '4px' }}
/>
{heatmapDays.filter((d) => d.completed).length} of 30 days
</span>
<span>Today</span>
</div>
</div>
</section>
{/* Edit modal */}
{showEditModal && (
<HabitFormModal
isOpen={showEditModal}
onDismiss={() => setShowEditModal(false)}
onSubmit={updateHabit}
initialValues={{
name: habit.name,
targetDescription: habit.target_description ?? '',
color: habit.color,
}}
/>
)}
</>
)}
</div>
{/* Delete confirmation */}
<IonAlert
isOpen={showDeleteAlert}
onDidDismiss={() => setShowDeleteAlert(false)}
header="Delete habit?"
message={`Are you sure you want to delete "${habit?.name ?? ''}"? This will also remove all its completion history. This action cannot be undone.`}
buttons={[
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Delete',
role: 'destructive',
handler: deleteHabit,
},
]}
/>
</IonContent>
</IonPage>
);
};
export default HabitDetail;
+435 -502
View File
@@ -1,557 +1,490 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
IonButton,
IonCard,
IonCardContent,
IonChip,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonPage,
IonSearchbar,
IonText,
IonTitle,
IonRefresher,
IonRefresherContent,
IonSkeletonText,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import {
bagHandleOutline,
cartOutline,
addOutline,
analyticsOutline,
checkmarkCircle,
personCircleOutline,
receiptOutline,
searchOutline,
star,
sparklesOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import ProductFilters, { SortOption } from '../components/ProductFilters';
import { useAuth } from '../context/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
import avocadoOilImage from '../assets/organic-avocado-oil.png';
import sourdoughBreadImage from '../assets/artisan-sourdough-bread.png';
import strawberriesPackImage from '../assets/fresh-strawberries-pack.png';
import farmEggsImage from '../assets/farm-eggs-carton.png';
import bananasImage from '../assets/bananas-bunch.png';
import wholeMilkImage from '../assets/whole-milk-carton.png';
import brownRiceImage from '../assets/brown-rice-bag.png';
import chickenBreastImage from '../assets/chicken-breast-pack.png';
import orangeJuiceImage from '../assets/orange-juice-bottle.png';
import rolledOatsImage from '../assets/rolled-oats-pack.png';
import greekYogurtImage from '../assets/greek-yogurt-cup.png';
import peanutButterImage from '../assets/peanut-butter-jar.png';
import spaghettiImage from '../assets/pasta-spaghetti-pack.png';
import tomatoSauceImage from '../assets/tomato-sauce-jar.png';
import cheddarCheeseImage from '../assets/cheddar-cheese-block.png';
import lettuceImage from '../assets/lettuce-head.png';
import carrotsImage from '../assets/carrots-bag.png';
import potatoesImage from '../assets/potatoes-bag.png';
import onionsImage from '../assets/onions-net-bag.png';
import salmonImage from '../assets/salmon-fillet-pack.png';
import sparklingWaterImage from '../assets/sparkling-water-pack.png';
import coffeeBeansImage from '../assets/coffee-beans-bag.png';
import greenTeaImage from '../assets/green-tea-box.png';
import oliveOilImage from '../assets/olive-oil-bottle.png';
import { PushNotifications } from '@capacitor/push-notifications';
import { Capacitor } from '@capacitor/core';
import { Redirect, useHistory } from 'react-router-dom';
import type { Tables } from '../database.types';
import { supabase } from '../supabase';
import EmptyState from '../components/EmptyState';
import HabitCard from '../components/HabitCard';
import HabitFormModal from '../components/HabitFormModal';
export type Product = {
id: string;
name: string;
category: string;
description: string;
price: number;
rating: number;
prepTime: string;
image: string;
imageAlt: string;
};
export type CartItem = {
id: string;
name: string;
price: number;
quantity: number;
};
export const CART_STORAGE_KEY = 'simple-shop-cart';
export const LAST_ORDER_STORAGE_KEY = 'simple-shop-last-order';
export const products: Product[] = [
{
id: '1',
name: 'Organic Avocado Oil',
category: 'Pantry',
description:
'Cold-pressed avocado oil for cooking, roasting, and healthy everyday meals.',
price: 8.99,
rating: 4.8,
prepTime: 'Ready to ship',
image: avocadoOilImage,
imageAlt: 'Bottle of organic avocado oil with fresh avocados',
},
{
id: '2',
name: 'Artisan Sourdough Bread',
category: 'Bakery',
description:
'Freshly baked sourdough loaf with a crisp crust and soft, airy center.',
price: 7.49,
rating: 4.6,
prepTime: 'Baked today',
image: sourdoughBreadImage,
imageAlt: 'Fresh artisan sourdough bread loaf on a wooden board',
},
{
id: '3',
name: 'Fresh Strawberries Pack',
category: 'Fruit',
description:
'Sweet, bright strawberries packed fresh for snacks, desserts, and smoothies.',
price: 9.25,
rating: 4.9,
prepTime: 'Picked fresh',
image: strawberriesPackImage,
imageAlt: 'Pack of fresh strawberries with berries beside it',
},
{
id: '4',
name: 'Free Range Farm Eggs',
category: 'Dairy & Eggs',
description:
'Farm-fresh eggs with rich yolks, ideal for breakfast, baking, and meal prep.',
price: 4.5,
rating: 4.7,
prepTime: 'Stocked daily',
image: farmEggsImage,
imageAlt: 'Carton of free range farm eggs on a clean background',
},
{
id: '5',
name: 'Bananas Bunch',
category: 'Fruit',
description:
'Naturally sweet ripe bananas for breakfast bowls, snacks, and smoothies.',
price: 3.2,
rating: 4.7,
prepTime: 'Fresh arrival',
image: bananasImage,
imageAlt: 'Fresh bunch of ripe yellow bananas',
},
{
id: '6',
name: 'Whole Milk Carton',
category: 'Dairy & Eggs',
description:
'Creamy whole milk for cereal, coffee, cooking, and family breakfasts.',
price: 3.99,
rating: 4.5,
prepTime: 'Kept chilled',
image: wholeMilkImage,
imageAlt: 'Carton of whole milk standing upright',
},
{
id: '7',
name: 'Brown Rice Bag',
category: 'Pantry',
description:
'Nutty wholegrain brown rice for bowls, sides, and meal prep dinners.',
price: 5.75,
rating: 4.6,
prepTime: 'Pantry staple',
image: brownRiceImage,
imageAlt: 'Sealed bag of brown rice on a white background',
},
{
id: '8',
name: 'Chicken Breast Pack',
category: 'Meat & Seafood',
description:
'Lean chicken breast fillets for grilling, roasting, and protein-packed meals.',
price: 11.9,
rating: 4.8,
prepTime: 'Packed fresh',
image: chickenBreastImage,
imageAlt: 'Pack of fresh chicken breast fillets',
},
{
id: '9',
name: 'Orange Juice Bottle',
category: 'Beverages',
description:
'Bright citrus orange juice for a refreshing breakfast or midday boost.',
price: 4.8,
rating: 4.6,
prepTime: 'Served chilled',
image: orangeJuiceImage,
imageAlt: 'Bottle of orange juice on a clean background',
},
{
id: '10',
name: 'Rolled Oats Pack',
category: 'Breakfast',
description:
'Hearty rolled oats for porridge, overnight oats, and healthy baking.',
price: 4.25,
rating: 4.7,
prepTime: 'Breakfast ready',
image: rolledOatsImage,
imageAlt: 'Pack of rolled oats on a white background',
},
{
id: '11',
name: 'Greek Yogurt Cup',
category: 'Dairy & Eggs',
description:
'Thick plain Greek yogurt with a rich texture for breakfast and snacks.',
price: 2.65,
rating: 4.5,
prepTime: 'Kept chilled',
image: greekYogurtImage,
imageAlt: 'Single cup of plain Greek yogurt',
},
{
id: '12',
name: 'Peanut Butter Jar',
category: 'Pantry',
description:
'Smooth peanut butter for toast, sandwiches, smoothies, and baking.',
price: 5.2,
rating: 4.8,
prepTime: 'Pantry staple',
image: peanutButterImage,
imageAlt: 'Jar of peanut butter on a white background',
},
{
id: '13',
name: 'Spaghetti Pasta Pack',
category: 'Pantry',
description:
'Classic spaghetti pasta for quick weeknight dinners and family meals.',
price: 3.45,
rating: 4.6,
prepTime: 'Dinner essential',
image: spaghettiImage,
imageAlt: 'Pack of spaghetti pasta on a clean background',
},
{
id: '14',
name: 'Tomato Sauce Jar',
category: 'Pantry',
description:
'Rich tomato sauce for pasta, pizza bases, and simmered home cooking.',
price: 3.95,
rating: 4.7,
prepTime: 'Ready to cook',
image: tomatoSauceImage,
imageAlt: 'Glass jar of tomato sauce',
},
{
id: '15',
name: 'Cheddar Cheese Block',
category: 'Dairy & Eggs',
description:
'Sharp cheddar cheese block for sandwiches, snacks, sauces, and grating.',
price: 6.15,
rating: 4.7,
prepTime: 'Kept chilled',
image: cheddarCheeseImage,
imageAlt: 'Block of cheddar cheese on a white background',
},
{
id: '16',
name: 'Fresh Lettuce Head',
category: 'Vegetables',
description:
'Crisp green lettuce for salads, wraps, burgers, and light lunches.',
price: 2.8,
rating: 4.4,
prepTime: 'Harvest fresh',
image: lettuceImage,
imageAlt: 'Fresh green head of lettuce',
},
{
id: '17',
name: 'Carrots Bag',
category: 'Vegetables',
description:
'Sweet crunchy carrots for roasting, soups, stir-fries, and lunch boxes.',
price: 3.1,
rating: 4.5,
prepTime: 'Stocked today',
image: carrotsImage,
imageAlt: 'Bag of fresh carrots on a clean background',
},
{
id: '18',
name: 'Potatoes Bag',
category: 'Vegetables',
description:
'Versatile potatoes for frying, mashing, baking, and hearty dinners.',
price: 4.75,
rating: 4.6,
prepTime: 'Kitchen essential',
image: potatoesImage,
imageAlt: 'Mesh bag of potatoes',
},
{
id: '19',
name: 'Onions Net Bag',
category: 'Vegetables',
description:
'Flavorful onions for stews, sauces, sautés, and everyday cooking.',
price: 3.35,
rating: 4.4,
prepTime: 'Stocked today',
image: onionsImage,
imageAlt: 'Net bag of onions on a white background',
},
{
id: '20',
name: 'Salmon Fillet Pack',
category: 'Meat & Seafood',
description:
'Fresh salmon fillet packed for quick oven dinners and healthy lunches.',
price: 13.99,
rating: 4.8,
prepTime: 'Packed fresh',
image: salmonImage,
imageAlt: 'Packaged salmon fillet on a clean background',
},
{
id: '21',
name: 'Sparkling Water Pack',
category: 'Beverages',
description:
'Refreshing sparkling water multipack for hydration and light entertaining.',
price: 6.8,
rating: 4.5,
prepTime: 'Ready to chill',
image: sparklingWaterImage,
imageAlt: 'Multipack of sparkling water cans',
},
{
id: '22',
name: 'Coffee Beans Bag',
category: 'Beverages',
description:
'Roasted coffee beans with a rich aroma for espresso and filter brews.',
price: 9.6,
rating: 4.8,
prepTime: 'Fresh roast',
image: coffeeBeansImage,
imageAlt: 'Bag of roasted coffee beans on a white background',
},
{
id: '23',
name: 'Green Tea Box',
category: 'Beverages',
description:
'Soothing green tea for a light daily ritual and calming warm drink.',
price: 4.4,
rating: 4.5,
prepTime: 'Tea time ready',
image: greenTeaImage,
imageAlt: 'Box of green tea on a clean background',
},
{
id: '24',
name: 'Extra Virgin Olive Oil',
category: 'Pantry',
description:
'Fragrant olive oil for salads, drizzling, roasting, and Mediterranean meals.',
price: 10.5,
rating: 4.9,
prepTime: 'Ready to ship',
image: oliveOilImage,
imageAlt: 'Bottle of extra virgin olive oil on a white background',
},
];
export const readCart = (): CartItem[] => {
if (typeof window === 'undefined') return [];
try {
const savedCart = window.localStorage.getItem(CART_STORAGE_KEY);
if (!savedCart) return [];
const parsed = JSON.parse(savedCart) as CartItem[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
interface HabitWithMeta extends Tables<'habits'> {
completions: Tables<'habit_completions'>[];
}
};
export const writeCart = (cart: CartItem[]) => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cart));
window.dispatchEvent(new CustomEvent('cart:updated'));
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
const calculateCurrentStreak = (dates: string[]) => {
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
let streak = 0;
let cursor = new Date();
if (!unique.includes(formatDate(cursor))) {
cursor.setDate(cursor.getDate() - 1);
}
while (unique.includes(formatDate(cursor))) {
streak += 1;
cursor.setDate(cursor.getDate() - 1);
}
return streak;
};
const Home: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [query, setQuery] = useState('');
const [cartCount, setCartCount] = useState(0);
const [selectedCategory, setSelectedCategory] = useState('All');
const [sortOption, setSortOption] = useState<SortOption>('popular');
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [sessionChecked, setSessionChecked] = useState(false);
const [userId, setUserId] = useState<string | null>(null);
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [busyHabitId, setBusyHabitId] = useState<string | null>(null);
const pushTokenRef = useRef<string | null>(null);
const pushListenersReadyRef = useRef(false);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
const count = readCart().reduce((sum, item) => sum + item.quantity, 0);
setCartCount(count);
const showError = (message: string) => {
setError(message);
window.setTimeout(() => setError(''), 4000);
};
const registerPushNotifications = useCallback(
async (currentUserId: string) => {
if (!Capacitor.isNativePlatform()) {
return;
}
if (!pushListenersReadyRef.current) {
pushListenersReadyRef.current = true;
await PushNotifications.removeAllListeners();
PushNotifications.addListener('registration', async (token) => {
if (pushTokenRef.current === token.value) {
return;
}
pushTokenRef.current = token.value;
const { error: updateError } = await supabase
.from('habits')
.update({ fcm_token: token.value })
.eq('user_id', currentUserId);
if (updateError) {
showError(updateError.message);
}
});
PushNotifications.addListener(
'registrationError',
(registrationError) => {
showError(registrationError.error ?? 'Push registration failed.');
}
);
}
const permissionStatus = await PushNotifications.checkPermissions();
if (permissionStatus.receive === 'prompt') {
const requested = await PushNotifications.requestPermissions();
if (requested.receive !== 'granted') {
return;
}
} else if (permissionStatus.receive !== 'granted') {
return;
}
await PushNotifications.register();
},
[showError]
);
const loadDashboard = useCallback(async () => {
setError('');
const {
data: { session },
error: sessionError,
} = await supabase.auth.getSession();
if (sessionError) {
setError(sessionError.message);
setSessionChecked(true);
setLoading(false);
return;
}
if (!session?.user) {
setUserId(null);
setSessionChecked(true);
setLoading(false);
return;
}
setUserId(session.user.id);
setSessionChecked(true);
await registerPushNotifications(session.user.id);
const { data, error: habitsError } = await supabase
.from('habits')
.select('*, completions:habit_completions(*)')
.order('created_at', { ascending: true });
if (habitsError) {
setError(habitsError.message);
setLoading(false);
return;
}
setHabits((data ?? []) as HabitWithMeta[]);
setLoading(false);
}, [registerPushNotifications]);
useEffect(() => {
loadDashboard();
}, [loadDashboard]);
const today = formatDate(new Date());
const habitStats = useMemo(() => {
const last7Days = Array.from({ length: 7 }, (_, index) => {
const date = new Date();
date.setDate(date.getDate() - index);
return formatDate(date);
});
return habits.reduce<
Record<string, { streak: number; weekRate: number; doneToday: boolean }>
>((acc, habit) => {
const completionDates = habit.completions.map(
(entry) => entry.completed_on
);
const completedInWeek = completionDates.filter((date) =>
last7Days.includes(date)
).length;
acc[habit.id] = {
streak: calculateCurrentStreak(completionDates),
weekRate: Math.round((completedInWeek / last7Days.length) * 100),
doneToday: completionDates.includes(today),
};
return acc;
}, {});
}, [habits, today]);
const totalCompletedToday = habits.filter(
(habit) => habitStats[habit.id]?.doneToday
).length;
const longestStreak = habits.reduce(
(max, habit) => Math.max(max, habitStats[habit.id]?.streak ?? 0),
0
);
const completionRatio = habits.length
? totalCompletedToday / habits.length
: 0;
const pendingCount = Math.max(habits.length - totalCompletedToday, 0);
const createHabit = async (values: {
name: string;
targetDescription: string;
color: string;
}) => {
if (!userId) throw new Error('You need to be logged in.');
const { error: insertError } = await supabase.from('habits').insert({
user_id: userId,
name: values.name,
target_description: values.targetDescription || null,
color: values.color,
});
if (insertError) throw insertError;
await loadDashboard();
};
const toggleHabit = async (habit: HabitWithMeta) => {
if (!userId) return;
setBusyHabitId(habit.id);
const existing = habit.completions.find(
(entry) => entry.completed_on === today
);
if (existing) {
const { error: deleteError } = await supabase
.from('habit_completions')
.delete()
.eq('id', existing.id);
if (deleteError) showError(deleteError.message);
} else {
const { error: insertError } = await supabase
.from('habit_completions')
.insert({
habit_id: habit.id,
user_id: userId,
completed_on: today,
});
if (insertError) {
showError(insertError.message);
} else {
const { error: pushError } = await supabase.functions.invoke(
'send-habit-completion-push',
{
body: { habitId: habit.id },
}
);
if (pushError) {
showError(pushError.message);
}
}
}
await loadDashboard();
setBusyHabitId(null);
};
const deleteHabit = async (habitId: string) => {
setBusyHabitId(habitId);
const { error: deleteError } = await supabase
.from('habits')
.delete()
.eq('id', habitId);
if (deleteError) {
showError(deleteError.message);
}
await loadDashboard();
setBusyHabitId(null);
};
useEffect(() => {
const syncCount = () =>
setCartCount(readCart().reduce((sum, item) => sum + item.quantity, 0));
syncCount();
window.addEventListener('storage', syncCount);
window.addEventListener('cart:updated', syncCount as EventListener);
return () => {
window.removeEventListener('storage', syncCount);
window.removeEventListener('cart:updated', syncCount as EventListener);
void PushNotifications.removeAllListeners();
};
}, []);
const categories = useMemo(
() => Array.from(new Set(products.map((product) => product.category))),
[]
);
const filteredProducts = useMemo(() => {
const term = query.toLowerCase().trim();
const nextProducts = products.filter((product) => {
const matchesCategory =
selectedCategory === 'All' || product.category === selectedCategory;
const matchesQuery =
!term ||
[product.name, product.category, product.description]
.join(' ')
.toLowerCase()
.includes(term);
return matchesCategory && matchesQuery;
});
const sorted = [...nextProducts];
if (sortOption === 'price-asc') sorted.sort((a, b) => a.price - b.price);
if (sortOption === 'price-desc') sorted.sort((a, b) => b.price - a.price);
if (sortOption === 'name-asc')
sorted.sort((a, b) => a.name.localeCompare(b.name));
if (sortOption === 'popular') sorted.sort((a, b) => b.rating - a.rating);
return sorted;
}, [query, selectedCategory, sortOption]);
if (sessionChecked && !userId) {
return <Redirect to="/auth" />;
}
return (
<IonPage>
<IonHeader className="shop-header ion-no-border">
<IonHeader className="ion-no-border home-header">
<IonToolbar>
<IonTitle className="home-title">QuickCart</IonTitle>
<div slot="end" className="home-header-actions">
<IonButtons slot="end">
<IonButton
fill="clear"
className="header-icon-button"
onClick={() => history.push('/orders')}
aria-label="Track orders"
className="soft-icon-button"
onClick={() => history.push('/stats')}
>
<IonIcon icon={receiptOutline} />
<IonIcon icon={analyticsOutline} />
</IonButton>
<IonButton
fill="clear"
className="cart-button pill-cart-button"
onClick={() => history.push('/cart')}
aria-label="View cart"
>
<IonIcon icon={cartOutline} />
<span>{cartCount}</span>
</IonButton>
<IonButton
fill="clear"
className="header-icon-button"
onClick={() => history.push(user ? '/profile' : '/auth')}
aria-label="Profile"
className="soft-icon-button"
onClick={() => history.push('/account')}
>
<IonIcon icon={personCircleOutline} />
</IonButton>
</div>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent fullscreen className="shop-content">
<div className="hero-card">
<IonChip color="primary">Fast delivery</IonChip>
<h1>Find your next favorite meal.</h1>
<p>
Browse simple picks, add them to your cart, and place your order in
seconds.
</p>
</div>
<div className="catalog-section ion-padding">
<IonSearchbar
value={query}
onIonInput={(event) => setQuery(event.detail.value ?? '')}
placeholder="Search products"
searchIcon={searchOutline}
/>
<ProductFilters
categories={categories}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
sortOption={sortOption}
setSortOption={setSortOption}
isOpen={isFilterOpen}
onClose={() => setIsFilterOpen((value) => !value)}
/>
<div className="section-heading">
<div>
<h2>Popular products</h2>
<IonText color="medium">
<p>{filteredProducts.length} items available</p>
</IonText>
</div>
</div>
{filteredProducts.length === 0 ? (
<div className="empty-state">
<IonIcon icon={bagHandleOutline} />
<h3>No products found</h3>
<p>Try a different search term or category.</p>
<IonButton
onClick={() => {
setQuery('');
setSelectedCategory('All');
<IonContent fullscreen className="home-content">
<IonRefresher
slot="fixed"
onIonRefresh={async (e) => {
await loadDashboard();
e.detail.complete();
}}
>
Clear filters
<IonRefresherContent />
</IonRefresher>
<div className="dashboard-shell home-shell ion-padding">
<section className="habits-hero">
<div className="habits-hero-top">
<div>
<p className="hero-kicker">Habits ({habits.length})</p>
<div className="hero-title-wrap">
<h1>Daily</h1>
<span>Weekly</span>
</div>
</div>
<IonButton
className="hero-add-button"
fill="clear"
onClick={() => setShowCreateModal(true)}
>
<IonIcon icon={addOutline} />
</IonButton>
</div>
) : (
<div className="product-grid">
{filteredProducts.map((product) => (
<IonCard
key={product.id}
button
className="product-card"
onClick={() => history.push(`/product/${product.id}`)}
<div className="hero-progress-track" aria-hidden="true">
<div
className="hero-progress-fill"
style={{ width: `${Math.max(completionRatio * 100, 6)}%` }}
>
<img
src={product.image}
alt={product.imageAlt}
className="product-card-image"
<span className="hero-progress-dot" />
</div>
</div>
<div className="hero-progress-meta">
<span>{totalCompletedToday} completed today</span>
<span>{Math.round(completionRatio * 100)}% completed</span>
</div>
</section>
{loading ? (
<div className="home-routine-panel">
<div className="stack-list home-habit-stack">
{Array.from({ length: 4 }).map((_, index) => (
<div
className="habit-card habit-card--editorial habit-card-skeleton"
key={`habit-skeleton-${index}`}
aria-hidden="true"
>
<IonSkeletonText
animated
className="habit-skeleton-check"
/>
<div className="habit-card-main">
<div className="habit-copy">
<div className="habit-copy-topline">
<IonSkeletonText
animated
className="habit-skeleton-title"
/>
<IonSkeletonText
animated
className="habit-skeleton-color-pill"
/>
</div>
<IonSkeletonText
animated
className="habit-skeleton-description"
/>
<div className="habit-metrics-row">
<IonSkeletonText
animated
className="habit-skeleton-metric"
/>
<IonSkeletonText
animated
className="habit-skeleton-metric short"
/>
<IonCardContent>
<div className="product-card-top">
<IonChip color="secondary">{product.category}</IonChip>
<div className="rating-pill">
<IonIcon icon={star} />
<span>{product.rating}</span>
</div>
</div>
<h3>{product.name}</h3>
<p>{product.description}</p>
<div className="product-card-footer">
<strong>${product.price.toFixed(2)}</strong>
<span>{product.prepTime}</span>
</div>
</IonCardContent>
</IonCard>
<IonSkeletonText
animated
className="habit-skeleton-action"
/>
</div>
))}
</div>
</div>
) : error ? (
<div className="inline-message error-card home-inline-card">
<h3>Couldn&apos;t load your habits</h3>
<p>{error}</p>
<IonButton onClick={loadDashboard}>Try Again</IonButton>
</div>
) : habits.length === 0 ? (
<div className="home-routine-panel">
<EmptyState
title="Start your first routine"
message="Create one small daily habit and begin building your streak story today."
actionLabel="Create First Habit"
onAction={() => setShowCreateModal(true)}
/>
</div>
) : (
<>
<section className="home-routine-panel">
<div className="stack-list home-habit-stack">
{habits.map((habit) => (
<HabitCard
key={habit.id}
habit={habit}
completedToday={habitStats[habit.id]?.doneToday ?? false}
currentStreak={habitStats[habit.id]?.streak ?? 0}
completionRate={habitStats[habit.id]?.weekRate ?? 0}
onToggle={() => toggleHabit(habit)}
onDelete={() => deleteHabit(habit.id)}
busy={busyHabitId === habit.id}
/>
))}
</div>
</section>
<section className="home-complete-section">
<p className="home-section-label">Momentum</p>
<div className="home-summary-card">
<div
className="summary-ring"
style={{
background: `conic-gradient(#7c3aed 0deg, #a855f7 ${completionRatio * 360}deg, rgba(109, 40, 217, 0.08) ${completionRatio * 360}deg 360deg)`,
}}
>
<div className="summary-ring-inner">
{Math.round(completionRatio * 100)}%
</div>
</div>
<div className="summary-copy">
<h3>Keep your rhythm</h3>
<p>
{pendingCount === 0
? 'Everything is complete today. Enjoy the streak.'
: `${pendingCount} habit${pendingCount === 1 ? '' : 's'} left to protect today's routine.`}
</p>
<div className="summary-metrics">
<span>
<IonIcon icon={sparklesOutline} /> {totalCompletedToday}{' '}
done
</span>
<span>
<IonIcon icon={checkmarkCircle} /> {longestStreak} day
best streak
</span>
</div>
</div>
</div>
</section>
</>
)}
</div>
<HabitFormModal
isOpen={showCreateModal}
onDismiss={() => setShowCreateModal(false)}
onSubmit={createHabit}
/>
</IonContent>
</IonPage>
);
-159
View File
@@ -1,159 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonList,
IonLoading,
IonPage,
IonRefresher,
IonRefresherContent,
IonText,
IonTitle,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { chevronForwardOutline, receiptOutline } from 'ionicons/icons';
import { RefresherEventDetail } from '@ionic/core';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import type { Tables } from '../database.types';
import { useAuth } from '../context/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
type Order = Tables<'orders'>;
const statusCopy: Record<string, string> = {
confirmed: 'Confirmed',
packed: 'Packed',
out_for_delivery: 'Out for delivery',
delivered: 'Delivered',
};
const MyOrders: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [orders, setOrders] = useState<Order[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
useIonViewWillEnter(() => {
void setStatusBarStyle(Style.Dark);
});
const loadOrders = async () => {
if (!user) {
setOrders([]);
setIsLoading(false);
return;
}
setIsLoading(true);
const { data, error } = await supabase
.from('orders')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false });
if (error) {
setErrorMessage('We could not load your orders right now.');
window.setTimeout(() => setErrorMessage(''), 4000);
setOrders([]);
setIsLoading(false);
return;
}
setOrders(data ?? []);
setIsLoading(false);
};
useEffect(() => {
void loadOrders();
}, [user?.id]);
const handleRefresh = async (event: CustomEvent<RefresherEventDetail>) => {
await loadOrders();
event.detail.complete();
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonButton fill="clear" onClick={() => history.replace('/home')}>
Back
</IonButton>
</IonButtons>
<IonTitle>My Orders</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<IonLoading isOpen={isLoading} message="Loading your orders..." />
{errorMessage && (
<IonText color="danger">
<p>{errorMessage}</p>
</IonText>
)}
{!user ? (
<div className="empty-state cart-empty">
<IonIcon icon={receiptOutline} />
<h3>Sign in to track orders</h3>
<p className="muted-text">
Your order history is available once you sign in.
</p>
<IonButton onClick={() => history.push('/auth')}>Sign in</IonButton>
</div>
) : !isLoading && orders.length === 0 ? (
<div className="empty-state cart-empty">
<IonIcon icon={receiptOutline} />
<h3>No orders yet</h3>
<p className="muted-text">
Your placed orders will appear here so you can track their status.
</p>
<IonButton onClick={() => history.push('/home')}>
Start shopping
</IonButton>
</div>
) : (
<IonList lines="none" className="orders-list">
{orders.map((order) => (
<button
key={order.id}
type="button"
className="order-card"
onClick={() => history.push(`/orders/${order.id}`)}
>
<div className="order-card-top">
<div>
<span className="order-label">Order</span>
<h3>{order.order_number}</h3>
</div>
<IonIcon icon={chevronForwardOutline} />
</div>
<div className="order-card-row">
<span>Status</span>
<strong>{statusCopy[order.status] ?? order.status}</strong>
</div>
<div className="order-card-row">
<span>Items</span>
<strong>{order.item_count}</strong>
</div>
<div className="order-card-row">
<span>Total</span>
<strong>${Number(order.total).toFixed(2)}</strong>
</div>
<div className="order-card-date">
{new Date(order.created_at).toLocaleString()}
</div>
</button>
))}
</IonList>
)}
</IonContent>
</IonPage>
);
};
export default MyOrders;
-149
View File
@@ -1,149 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
IonButton,
IonContent,
IonHeader,
IonIcon,
IonPage,
IonTitle,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { checkmarkCircle } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { LAST_ORDER_STORAGE_KEY } from './Home';
import {
setStatusBarBackground,
setStatusBarStyle,
Style,
} from '../utils/statusBar';
type OrderInfo = {
id: string;
orderNumber: string;
total: number;
itemCount: number;
status: string;
customerName: string;
customerPhone: string;
deliveryAddress: string;
deliveryNotes: string;
};
const OrderConfirmation: React.FC = () => {
const history = useHistory();
const [order, setOrder] = useState<OrderInfo | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
setStatusBarBackground('#ffffff');
});
useEffect(() => {
try {
const savedOrder = localStorage.getItem(LAST_ORDER_STORAGE_KEY);
if (!savedOrder) {
setOrder(null);
return;
}
setOrder(JSON.parse(savedOrder) as OrderInfo);
} catch {
setOrder(null);
}
}, []);
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonTitle>Order placed</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div className="confirmation-card">
<IonIcon icon={checkmarkCircle} color="success" />
<h1>Thanks for your order!</h1>
<p className="muted-text">
Your order has been placed successfully and is now being prepared.
</p>
{order ? (
<>
<div className="confirmation-details">
<div>
<span>Order number</span>
<strong>{order.orderNumber}</strong>
</div>
<div>
<span>Status</span>
<strong>{order.status.replaceAll('_', ' ')}</strong>
</div>
<div>
<span>Items</span>
<strong>{order.itemCount}</strong>
</div>
<div>
<span>Total paid</span>
<strong>${order.total.toFixed(2)}</strong>
</div>
</div>
<div className="delivery-details-card">
<h2>Delivery information</h2>
<div className="delivery-detail-row">
<span>Customer</span>
<strong>{order.customerName}</strong>
</div>
<div className="delivery-detail-row">
<span>Phone</span>
<strong>{order.customerPhone}</strong>
</div>
<div className="delivery-detail-row delivery-detail-block">
<span>Address</span>
<strong>{order.deliveryAddress}</strong>
</div>
{order.deliveryNotes && (
<div className="delivery-detail-row delivery-detail-block">
<span>Notes</span>
<strong>{order.deliveryNotes}</strong>
</div>
)}
</div>
</>
) : (
<p className="muted-text">
We could not load the order summary, but your next browse session
is ready.
</p>
)}
{order && (
<IonButton
expand="block"
onClick={() => history.replace(`/orders/${order.id}`)}
>
Track this order
</IonButton>
)}
<IonButton
fill="outline"
expand="block"
onClick={() => history.replace('/orders')}
>
View all orders
</IonButton>
<IonButton
fill="clear"
expand="block"
onClick={() => history.replace('/home')}
>
Continue shopping
</IonButton>
</div>
</IonContent>
</IonPage>
);
};
export default OrderConfirmation;
-237
View File
@@ -1,237 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonItem,
IonLabel,
IonList,
IonLoading,
IonPage,
IonRefresher,
IonRefresherContent,
IonText,
IonTitle,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { RefresherEventDetail } from '@ionic/core';
import { RouteComponentProps, useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import type { Tables } from '../database.types';
import { useAuth } from '../context/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
type Props = RouteComponentProps<{ id: string }>;
type Order = Tables<'orders'>;
type OrderItem = Tables<'order_items'>;
const statusSteps = [
'confirmed',
'packed',
'out_for_delivery',
'delivered',
] as const;
const statusTitles: Record<(typeof statusSteps)[number], string> = {
confirmed: 'Confirmed',
packed: 'Packed',
out_for_delivery: 'Out for delivery',
delivered: 'Delivered',
};
const OrderDetail: React.FC<Props> = ({ match }) => {
const history = useHistory();
const { user } = useAuth();
const [order, setOrder] = useState<Order | null>(null);
const [items, setItems] = useState<OrderItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
useIonViewWillEnter(() => {
void setStatusBarStyle(Style.Dark);
});
const loadOrder = async () => {
if (!user) {
setIsLoading(false);
return;
}
setIsLoading(true);
const [
{ data: orderData, error: orderError },
{ data: itemData, error: itemError },
] = await Promise.all([
supabase
.from('orders')
.select('*')
.eq('id', match.params.id)
.eq('user_id', user.id)
.single(),
supabase
.from('order_items')
.select('*')
.eq('order_id', match.params.id)
.order('created_at', { ascending: true }),
]);
if (orderError || itemError || !orderData) {
setErrorMessage('We could not load this order.');
window.setTimeout(() => setErrorMessage(''), 4000);
setOrder(null);
setItems([]);
setIsLoading(false);
return;
}
setOrder(orderData);
setItems(itemData ?? []);
setIsLoading(false);
};
useEffect(() => {
void loadOrder();
}, [match.params.id, user?.id]);
const activeStepIndex = useMemo(
() =>
order
? Math.max(
statusSteps.indexOf(order.status as (typeof statusSteps)[number]),
0
)
: 0,
[order]
);
const handleRefresh = async (event: CustomEvent<RefresherEventDetail>) => {
await loadOrder();
event.detail.complete();
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonButton fill="clear" onClick={() => history.replace('/orders')}>
Back
</IonButton>
</IonButtons>
<IonTitle>Track Order</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<IonLoading isOpen={isLoading} message="Loading order details..." />
{errorMessage && (
<IonText color="danger">
<p>{errorMessage}</p>
</IonText>
)}
{!user ? (
<div className="empty-state cart-empty">
<h3>Sign in to view order details</h3>
<IonButton onClick={() => history.replace('/auth')}>
Sign in
</IonButton>
</div>
) : !isLoading && !order ? (
<div className="empty-state cart-empty">
<h3>Order not found</h3>
<p className="muted-text">
We could not find that order. Try returning to your order list.
</p>
<IonButton onClick={() => history.replace('/orders')}>
Back to orders
</IonButton>
</div>
) : (
order && (
<>
<div className="tracking-summary-card">
<span className="order-label">Order number</span>
<h1>{order.order_number}</h1>
<p className="muted-text">
Placed on {new Date(order.created_at).toLocaleString()}
</p>
<div className="order-card-row">
<span>Current status</span>
<strong>
{statusTitles[
order.status as (typeof statusSteps)[number]
] ?? order.status}
</strong>
</div>
<div className="order-card-row">
<span>Total</span>
<strong>${Number(order.total).toFixed(2)}</strong>
</div>
</div>
<div className="timeline-card">
<h2>Delivery progress</h2>
<div className="tracking-timeline">
{statusSteps.map((step, index) => (
<div
key={step}
className={`timeline-step ${index <= activeStepIndex ? 'active' : ''}`}
>
<div className="timeline-dot" />
<div>
<strong>{statusTitles[step]}</strong>
<p className="muted-text">
{index < activeStepIndex
? 'Completed'
: index === activeStepIndex
? 'Current step'
: 'Up next'}
</p>
</div>
</div>
))}
</div>
</div>
<div className="timeline-card delivery-details-card">
<h2>Delivery details</h2>
<div className="delivery-detail-row">
<span>Customer</span>
<strong>{order.customer_name || '—'}</strong>
</div>
<div className="delivery-detail-row">
<span>Phone</span>
<strong>{order.customer_phone || '—'}</strong>
</div>
<div className="delivery-detail-row delivery-detail-block">
<span>Address</span>
<strong>{order.delivery_address || '—'}</strong>
</div>
{order.delivery_notes && (
<div className="delivery-detail-row delivery-detail-block">
<span>Notes</span>
<strong>{order.delivery_notes}</strong>
</div>
)}
</div>
<div className="timeline-card">
<h2>Items in this order</h2>
<IonList lines="none" className="cart-list">
{items.map((item) => (
<IonItem key={item.id} className="cart-item">
<IonLabel>
<h2>{item.product_name}</h2>
<p>${Number(item.unit_price).toFixed(2)} each</p>
<strong>
Qty {item.quantity} · $
{(Number(item.unit_price) * item.quantity).toFixed(2)}
</strong>
</IonLabel>
</IonItem>
))}
</IonList>
</div>
</>
)
)}
</IonContent>
</IonPage>
);
};
export default OrderDetail;
-210
View File
@@ -1,210 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
IonBadge,
IonButton,
IonButtons,
IonChip,
IonContent,
IonHeader,
IonIcon,
IonPage,
IonText,
IonTitle,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { addOutline, cartOutline, removeOutline, star } from 'ionicons/icons';
import { RouteComponentProps, useHistory } from 'react-router-dom';
import { CartItem, products, readCart, writeCart } from './Home';
import {
setStatusBarBackground,
setStatusBarStyle,
Style,
} from '../utils/statusBar';
type Props = RouteComponentProps<{ id: string }>;
const ProductDetail: React.FC<Props> = ({ match }) => {
const history = useHistory();
const [quantity, setQuantity] = useState(1);
const [message, setMessage] = useState('');
const [cartCount, setCartCount] = useState(0);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
setStatusBarBackground('#ffffff');
const count = readCart().reduce((sum, item) => sum + item.quantity, 0);
setCartCount(count);
});
useEffect(() => {
const syncCount = () => {
const count = readCart().reduce((sum, item) => sum + item.quantity, 0);
setCartCount(count);
};
syncCount();
window.addEventListener('storage', syncCount);
window.addEventListener('cart:updated', syncCount as EventListener);
return () => {
window.removeEventListener('storage', syncCount);
window.removeEventListener('cart:updated', syncCount as EventListener);
};
}, []);
const product = useMemo(
() => products.find((item) => item.id === match.params.id),
[match.params.id]
);
const showMessage = (text: string) => {
setMessage(text);
window.setTimeout(() => setMessage(''), 4000);
};
const addToCart = () => {
if (!product) return;
const cart: CartItem[] = readCart();
const existing = cart.find((item) => item.id === product.id);
const nextCart = existing
? cart.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + quantity }
: item
)
: [
...cart,
{
id: product.id,
name: product.name,
price: product.price,
quantity,
},
];
writeCart(nextCart);
showMessage('Added to cart');
};
if (!product) {
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonButton fill="clear" onClick={() => history.replace('/home')}>
Back
</IonButton>
</IonButtons>
<IonTitle>Product</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div className="empty-state">
<h3>Product not found</h3>
<p className="muted-text">This product is no longer available.</p>
<IonButton onClick={() => history.replace('/home')}>
Browse products
</IonButton>
</div>
</IonContent>
</IonPage>
);
}
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonButton fill="clear" onClick={() => history.replace('/home')}>
Back
</IonButton>
</IonButtons>
<IonTitle>{product.name}</IonTitle>
<IonButtons slot="end">
<IonButton
fill="clear"
className="cart-button pill-cart-button"
onClick={() => history.push('/cart')}
>
<IonIcon icon={cartOutline} />
<span>{cartCount}</span>
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<div className="detail-hero ion-padding">
<div className="detail-image-wrap">
<img
src={product.image}
alt={product.imageAlt}
className="detail-image"
/>
</div>
<IonChip color="secondary">{product.category}</IonChip>
<h1>{product.name}</h1>
<div className="detail-meta">
<IonBadge color="light">
<IonIcon icon={star} /> {product.rating}
</IonBadge>
<IonBadge color="light">Ready in {product.prepTime}</IonBadge>
</div>
<p className="muted-text">{product.description}</p>
<div className="detail-price">${product.price.toFixed(2)}</div>
</div>
<div className="detail-panel ion-padding">
<h2>Choose quantity</h2>
<div className="quantity-row">
<IonButton
fill="outline"
onClick={() => setQuantity((value) => Math.max(1, value - 1))}
>
<IonIcon icon={removeOutline} />
</IonButton>
<span>{quantity}</span>
<IonButton
fill="outline"
onClick={() => setQuantity((value) => value + 1)}
>
<IonIcon icon={addOutline} />
</IonButton>
</div>
<div className="order-box">
<div>
<IonText color="medium">
<p>Total</p>
</IonText>
<strong>${(product.price * quantity).toFixed(2)}</strong>
</div>
<IonButton expand="block" onClick={addToCart}>
Add to cart
</IonButton>
</div>
{message && (
<IonText color="success">
<p>{message}</p>
</IonText>
)}
<IonButton
fill="clear"
expand="block"
onClick={() => history.push('/cart')}
>
Go to cart
</IonButton>
</div>
</IonContent>
</IonPage>
);
};
export default ProductDetail;
-131
View File
@@ -1,131 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonInput,
IonItem,
IonLabel,
IonPage,
IonText,
IonTitle,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../context/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const Profile: React.FC = () => {
const history = useHistory();
const { user, profile, refreshProfile, signOut } = useAuth();
const [fullName, setFullName] = useState('');
const [phone, setPhone] = useState('');
const [message, setMessage] = useState('');
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
useEffect(() => {
setFullName(profile?.full_name ?? '');
setPhone(profile?.phone ?? '');
}, [profile]);
const showMessage = (msg: string) => {
setMessage(msg);
window.setTimeout(() => setMessage(''), 4000);
};
const saveProfile = async () => {
if (!user) return;
const { error } = await supabase.from('profiles').upsert({
id: user.id,
full_name: fullName.trim() || null,
phone: phone.trim() || null,
updated_at: new Date().toISOString(),
});
if (!error) {
await refreshProfile();
showMessage('Profile saved.');
}
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonButton fill="clear" onClick={() => history.replace('/home')}>
Back
</IonButton>
</IonButtons>
<IonTitle>Profile</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div className="auth-card">
<h1>Your account</h1>
<p className="muted-text">Signed in as {user?.email}</p>
{message && (
<IonText color="success">
<p>{message}</p>
</IonText>
)}
<div className="auth-form">
<IonItem lines="none" className="auth-input-item">
<IonLabel position="stacked">Full name</IonLabel>
<IonInput
value={fullName}
onIonInput={(e) => setFullName(e.detail.value ?? '')}
/>
</IonItem>
<IonItem lines="none" className="auth-input-item">
<IonLabel position="stacked">Phone</IonLabel>
<IonInput
value={phone}
type="tel"
onIonInput={(e) => setPhone(e.detail.value ?? '')}
/>
</IonItem>
<IonButton expand="block" onClick={saveProfile}>
Save profile
</IonButton>
<IonButton
expand="block"
fill="outline"
onClick={() => history.push('/addresses')}
>
Saved addresses
</IonButton>
{profile?.is_admin && (
<IonButton
expand="block"
fill="outline"
onClick={() => history.push('/admin/orders')}
>
Admin workflow
</IonButton>
)}
<IonButton
color="medium"
expand="block"
fill="clear"
onClick={async () => {
await signOut();
history.replace('/auth');
}}
>
Sign out
</IonButton>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default Profile;
+174
View File
@@ -0,0 +1,174 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonPage,
IonSegment,
IonSegmentButton,
IonTitle,
IonToolbar,
} from '@ionic/react';
import { IonBackButton } from '@ionic/react';
import { calendarOutline } from 'ionicons/icons';
import { Redirect } from 'react-router-dom';
import type { Tables } from '../database.types';
import StatsHabitCard from '../components/StatsHabitCard';
import { supabase } from '../supabase';
import {
Style,
setStatusBarBackground,
setStatusBarStyle,
} from '../utils/statusBar';
import { useIonViewWillEnter } from '@ionic/react';
interface HabitWithMeta extends Tables<'habits'> {
completions: Tables<'habit_completions'>[];
}
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
const calculateCurrentStreak = (dates: string[]) => {
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
let streak = 0;
let cursor = new Date();
if (!unique.includes(formatDate(cursor))) {
cursor.setDate(cursor.getDate() - 1);
}
while (unique.includes(formatDate(cursor))) {
streak += 1;
cursor.setDate(cursor.getDate() - 1);
}
return streak;
};
const Stats: React.FC = () => {
const [period, setPeriod] = useState<'7' | '30'>('7');
const [sessionChecked, setSessionChecked] = useState(false);
const [userId, setUserId] = useState<string | null>(null);
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
useEffect(() => {
const load = async () => {
const {
data: { session },
} = await supabase.auth.getSession();
if (!session?.user) {
setSessionChecked(true);
setUserId(null);
return;
}
setUserId(session.user.id);
setSessionChecked(true);
const { data } = await supabase
.from('habits')
.select('*, completions:habit_completions(*)')
.order('created_at', { ascending: true });
setHabits((data ?? []) as HabitWithMeta[]);
};
load();
}, []);
const days = Number(period);
const visibleDates = useMemo(() => {
return Array.from({ length: days }, (_, index) => {
const date = new Date();
date.setDate(date.getDate() - index);
return formatDate(date);
});
}, [days]);
const summary = useMemo(() => {
return habits.map((habit) => {
const completionDates = habit.completions.map(
(entry) => entry.completed_on,
);
const completedCount = completionDates.filter((date) =>
visibleDates.includes(date),
).length;
return {
id: habit.id,
name: habit.name,
color: habit.color,
completedCount,
streak: calculateCurrentStreak(completionDates),
};
});
}, [habits, visibleDates]);
if (sessionChecked && !userId) {
return <Redirect to="/auth" />;
}
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/home" />
</IonButtons>
<IonTitle>Stats</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<div className="dashboard-shell ion-padding">
<section className="hero-card compact">
<span className="eyebrow">Consistency view</span>
<h1>See which habits you&apos;re actually keeping.</h1>
<p>
Flip between the last week and month to spot patterns before a
streak disappears.
</p>
</section>
<div className="stats-filter-row">
<IonIcon icon={calendarOutline} />
<IonSegment
value={period}
onIonChange={(e) => setPeriod(e.detail.value as '7' | '30')}
>
<IonSegmentButton value="7">Last 7 days</IonSegmentButton>
<IonSegmentButton value="30">Last 30 days</IonSegmentButton>
</IonSegment>
</div>
{summary.length === 0 ? (
<div className="inline-message">
<h3>No stats yet</h3>
<p>
Create a habit and start checking it off daily to unlock
consistency insights.
</p>
<IonButton routerLink="/home">Go to dashboard</IonButton>
</div>
) : (
<div className="stack-list">
{summary.map((habit) => (
<StatsHabitCard
key={habit.id}
name={habit.name}
color={habit.color}
completedCount={habit.completedCount}
totalDays={days}
streak={habit.streak}
/>
))}
</div>
)}
</div>
</IonContent>
</IonPage>
);
};
export default Stats;
+2 -2
View File
@@ -2,6 +2,6 @@ import { createClient } from '@supabase/supabase-js';
import type { Database } from './database.types';
export const supabase = createClient<Database>(
'https://ztrqscwsjnfvcuaakqus.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inp0cnFzY3dzam5mdmN1YWFrcXVzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Nzk4ODk5MzEsImV4cCI6MjA5NTQ2NTkzMX0.htIOqPM90Uk0dYIdD8X8_tLfq5URcSvmCArfVRF4jeQ',
'https://zillycfjfbzofltgyjwy.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InppbGx5Y2ZqZmJ6b2ZsdGd5and5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkxMjYzODQsImV4cCI6MjA5NDcwMjM4NH0.BB4ccBEQI2krc7SseC73234Nfya-92VGcTELNQdzt50',
);
+808 -593
View File
File diff suppressed because it is too large Load Diff
+4 -13
View File
@@ -2,17 +2,13 @@ import { StatusBar, Style } from '@capacitor/status-bar';
export { Style };
const _origin = (() => {
try {
if (document.referrer) return new URL(document.referrer).origin;
} catch {}
try { if (document.referrer) return new URL(document.referrer).origin; } catch {}
return window.location.origin;
})();
/** Set status bar icon/text colour. Use instead of StatusBar.setStyle directly. */
export async function setStatusBarStyle(style: Style): Promise<void> {
try {
await StatusBar.setStyle({ style });
} catch {}
try { await StatusBar.setStyle({ style }); } catch {}
if (window.parent !== window) {
window.parent.postMessage({ type: '__apsuite_statusbar', style }, _origin);
}
@@ -20,13 +16,8 @@ export async function setStatusBarStyle(style: Style): Promise<void> {
/** Set status bar background colour (Android). Use instead of StatusBar.setBackgroundColor directly. */
export async function setStatusBarBackground(color: string): Promise<void> {
try {
await (StatusBar as any).setBackgroundColor({ color });
} catch {}
try { await (StatusBar as any).setBackgroundColor({ color }); } catch {}
if (window.parent !== window) {
window.parent.postMessage(
{ type: '__apsuite_statusbar_bg', color },
_origin
);
window.parent.postMessage({ type: '__apsuite_statusbar_bg', color }, _origin);
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": false,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
+2 -3
View File
@@ -5,12 +5,11 @@ const injectRouterBasename: Plugin = {
name: 'inject-router-basename',
enforce: 'pre',
transform(code, id) {
if (!/\.[jt]sx$/.test(id)) return null;
const next = code.replace(
if (!/\.[jt]sx$/.test(id)) return;
return code.replace(
/<IonReactRouter(?![^>]*basename)([^>]*)>/g,
'<IonReactRouter$1 basename={import.meta.env.BASE_URL}>',
);
return next === code ? null : next;
},
};
+1779 -3732
View File
File diff suppressed because it is too large Load Diff